diff --git a/.gitignore b/.gitignore
index 76b5cf8..d34b3e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,4 +18,4 @@ TODO.md
logs.txt
.idea/
secret.md
-app.env
\ No newline at end of file
+tmp/
\ No newline at end of file
diff --git a/app.env b/app.env
new file mode 100644
index 0000000..1cf5e00
--- /dev/null
+++ b/app.env
@@ -0,0 +1,19 @@
+POSTGRES_HOST=127.0.0.1
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=password123
+POSTGRES_DB=golang-gorm
+POSTGRES_PORT=6500
+
+PORT=8000
+CLIENT_ORIGIN=http://localhost:3000
+
+EMAIL_FROM=admin@admin.com
+SMTP_HOST=smtp.mailtrap.io
+SMTP_USER=90cf952fb44469
+SMTP_PASS=0524531956c552
+SMTP_PORT=587
+
+TOKEN_EXPIRED_IN=60m
+TOKEN_MAXAGE=60
+
+TOKEN_SECRET=my-ultra-secure-json-web-token-string
\ No newline at end of file
diff --git a/controllers/auth.controller.go b/controllers/auth.controller.go
index df7ee56..3d0b1df 100644
--- a/controllers/auth.controller.go
+++ b/controllers/auth.controller.go
@@ -1,12 +1,13 @@
package controllers
import (
- "fmt"
+ "log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
+ "github.com/thanhpk/randstr"
"github.com/wpcodevo/golang-gorm-postgres/initializers"
"github.com/wpcodevo/golang-gorm-postgres/models"
"github.com/wpcodevo/golang-gorm-postgres/utils"
@@ -21,7 +22,7 @@ func NewAuthController(DB *gorm.DB) AuthController {
return AuthController{DB}
}
-// SignUp User
+// [...] SignUp User
func (ac *AuthController) SignUpUser(ctx *gin.Context) {
var payload *models.SignUpInput
@@ -47,7 +48,7 @@ func (ac *AuthController) SignUpUser(ctx *gin.Context) {
Email: strings.ToLower(payload.Email),
Password: hashedPassword,
Role: "user",
- Verified: true,
+ Verified: false,
Photo: payload.Photo,
Provider: "local",
CreatedAt: now,
@@ -61,19 +62,37 @@ func (ac *AuthController) SignUpUser(ctx *gin.Context) {
return
}
- userResponse := &models.UserResponse{
- ID: newUser.ID,
- Name: newUser.Name,
- Email: newUser.Email,
- Photo: newUser.Photo,
- Role: newUser.Role,
- Provider: newUser.Provider,
- CreatedAt: newUser.CreatedAt,
- UpdatedAt: newUser.UpdatedAt,
+ config, _ := initializers.LoadConfig(".")
+
+ // Generate Verification Code
+ code := randstr.String(20)
+
+ verification_code := utils.Encode(code)
+
+ // Update User in Database
+ newUser.VerificationCode = verification_code
+ ac.DB.Save(newUser)
+
+ var firstName = newUser.Name
+
+ if strings.Contains(firstName, " ") {
+ firstName = strings.Split(firstName, " ")[1]
+ }
+
+ // 👇 Send Email
+ emailData := utils.EmailData{
+ URL: config.ClientOrigin + "/verifyemail/" + code,
+ FirstName: firstName,
+ Subject: "Your account verification code",
}
- ctx.JSON(http.StatusCreated, gin.H{"status": "success", "data": gin.H{"user": userResponse}})
+
+ utils.SendEmail(&newUser, &emailData, "verificationCode.html")
+
+ message := "We sent an email with a verification code to " + newUser.Email
+ ctx.JSON(http.StatusCreated, gin.H{"status": "success", "message": message})
}
+// [...] SignIn User
func (ac *AuthController) SignInUser(ctx *gin.Context) {
var payload *models.SignInInput
@@ -89,6 +108,11 @@ func (ac *AuthController) SignInUser(ctx *gin.Context) {
return
}
+ if !user.Verified {
+ ctx.JSON(http.StatusForbidden, gin.H{"status": "fail", "message": "Please verify your email"})
+ return
+ }
+
if err := utils.VerifyPassword(user.Password, payload.Password); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"status": "fail", "message": "Invalid email or Password"})
return
@@ -96,68 +120,132 @@ func (ac *AuthController) SignInUser(ctx *gin.Context) {
config, _ := initializers.LoadConfig(".")
- // Generate Tokens
- access_token, err := utils.CreateToken(config.AccessTokenExpiresIn, user.ID, config.AccessTokenPrivateKey)
+ // Generate Token
+ token, err := utils.GenerateToken(config.TokenExpiresIn, user.ID, config.TokenSecret)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"status": "fail", "message": err.Error()})
return
}
- refresh_token, err := utils.CreateToken(config.RefreshTokenExpiresIn, user.ID, config.RefreshTokenPrivateKey)
- if err != nil {
- ctx.JSON(http.StatusBadRequest, gin.H{"status": "fail", "message": err.Error()})
- return
- }
+ ctx.SetCookie("token", token, config.TokenMaxAge*60, "/", "localhost", false, true)
- ctx.SetCookie("access_token", access_token, config.AccessTokenMaxAge*60, "/", "localhost", false, true)
- ctx.SetCookie("refresh_token", refresh_token, config.RefreshTokenMaxAge*60, "/", "localhost", false, true)
- ctx.SetCookie("logged_in", "true", config.AccessTokenMaxAge*60, "/", "localhost", false, false)
+ ctx.JSON(http.StatusOK, gin.H{"status": "success", "token": token})
+}
- ctx.JSON(http.StatusOK, gin.H{"status": "success", "access_token": access_token})
+// [...] SignOut User
+func (ac *AuthController) LogoutUser(ctx *gin.Context) {
+ ctx.SetCookie("token", "", -1, "/", "localhost", false, true)
+ ctx.JSON(http.StatusOK, gin.H{"status": "success"})
}
-// Refresh Access Token
-func (ac *AuthController) RefreshAccessToken(ctx *gin.Context) {
- message := "could not refresh access token"
+// [...] Verify Email
+func (ac *AuthController) VerifyEmail(ctx *gin.Context) {
- cookie, err := ctx.Cookie("refresh_token")
+ code := ctx.Params.ByName("verificationCode")
+ verification_code := utils.Encode(code)
- if err != nil {
- ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"status": "fail", "message": message})
+ var updatedUser models.User
+ result := ac.DB.First(&updatedUser, "verification_code = ?", verification_code)
+ if result.Error != nil {
+ ctx.JSON(http.StatusBadRequest, gin.H{"status": "fail", "message": "Invalid verification code or user doesn't exists"})
return
}
- config, _ := initializers.LoadConfig(".")
+ if updatedUser.Verified {
+ ctx.JSON(http.StatusConflict, gin.H{"status": "fail", "message": "User already verified"})
+ return
+ }
- sub, err := utils.ValidateToken(cookie, config.RefreshTokenPublicKey)
- if err != nil {
- ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"status": "fail", "message": err.Error()})
+ updatedUser.VerificationCode = ""
+ updatedUser.Verified = true
+ ac.DB.Save(&updatedUser)
+
+ ctx.JSON(http.StatusOK, gin.H{"status": "success", "message": "Email verified successfully"})
+}
+
+func (ac *AuthController) ForgotPassword(ctx *gin.Context) {
+ var payload *models.ForgotPasswordInput
+
+ if err := ctx.ShouldBindJSON(&payload); err != nil {
+ ctx.JSON(http.StatusBadRequest, gin.H{"status": "fail", "message": err.Error()})
return
}
+ message := "You will receive a reset email if user with that email exist"
+
var user models.User
- result := ac.DB.First(&user, "id = ?", fmt.Sprint(sub))
+ result := ac.DB.First(&user, "email = ?", strings.ToLower(payload.Email))
if result.Error != nil {
- ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"status": "fail", "message": "the user belonging to this token no logger exists"})
+ ctx.JSON(http.StatusBadRequest, gin.H{"status": "fail", "message": "Invalid email or Password"})
return
}
- access_token, err := utils.CreateToken(config.AccessTokenExpiresIn, user.ID, config.AccessTokenPrivateKey)
- if err != nil {
- ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"status": "fail", "message": err.Error()})
+ if !user.Verified {
+ ctx.JSON(http.StatusUnauthorized, gin.H{"status": "error", "message": "Account not verified"})
return
}
- ctx.SetCookie("access_token", access_token, config.AccessTokenMaxAge*60, "/", "localhost", false, true)
- ctx.SetCookie("logged_in", "true", config.AccessTokenMaxAge*60, "/", "localhost", false, false)
+ config, err := initializers.LoadConfig(".")
+ if err != nil {
+ log.Fatal("Could not load config", err)
+ }
- ctx.JSON(http.StatusOK, gin.H{"status": "success", "access_token": access_token})
+ // Generate Verification Code
+ resetToken := randstr.String(20)
+
+ passwordResetToken := utils.Encode(resetToken)
+ user.PasswordResetToken = passwordResetToken
+ user.PasswordResetAt = time.Now().Add(time.Minute * 15)
+ ac.DB.Save(&user)
+
+ var firstName = user.Name
+
+ if strings.Contains(firstName, " ") {
+ firstName = strings.Split(firstName, " ")[1]
+ }
+
+ // 👇 Send Email
+ emailData := utils.EmailData{
+ URL: config.ClientOrigin + "/forgotPassword/" + resetToken,
+ FirstName: firstName,
+ Subject: "Your password reset token (valid for 10min)",
+ }
+
+ utils.SendEmail(&user, &emailData, "resetPassword.html")
+
+ ctx.JSON(http.StatusOK, gin.H{"status": "success", "message": message})
}
-func (ac *AuthController) LogoutUser(ctx *gin.Context) {
- ctx.SetCookie("access_token", "", -1, "/", "localhost", false, true)
- ctx.SetCookie("refresh_token", "", -1, "/", "localhost", false, true)
- ctx.SetCookie("logged_in", "", -1, "/", "localhost", false, false)
+func (ac *AuthController) ResetPassword(ctx *gin.Context) {
+ var payload *models.ResetPasswordInput
+ resetToken := ctx.Params.ByName("resetToken")
- ctx.JSON(http.StatusOK, gin.H{"status": "success"})
+ if err := ctx.ShouldBindJSON(&payload); err != nil {
+ ctx.JSON(http.StatusBadRequest, gin.H{"status": "fail", "message": err.Error()})
+ return
+ }
+
+ if payload.Password != payload.PasswordConfirm {
+ ctx.JSON(http.StatusBadRequest, gin.H{"status": "fail", "message": "Passwords do not match"})
+ return
+ }
+
+ hashedPassword, _ := utils.HashPassword(payload.Password)
+
+ passwordResetToken := utils.Encode(resetToken)
+
+ var updatedUser models.User
+ result := ac.DB.First(&updatedUser, "password_reset_token = ? AND password_reset_at > ?", passwordResetToken, time.Now())
+ if result.Error != nil {
+ ctx.JSON(http.StatusBadRequest, gin.H{"status": "fail", "message": "The reset token is invalid or has expired"})
+ return
+ }
+
+ updatedUser.Password = hashedPassword
+ updatedUser.PasswordResetToken = ""
+ ac.DB.Save(&updatedUser)
+
+ ctx.SetCookie("token", "", -1, "/", "localhost", false, true)
+
+ ctx.JSON(http.StatusOK, gin.H{"status": "success", "message": "Password data updated successfully"})
}
diff --git a/example.env b/example.env
index cb1c622..1cf5e00 100644
--- a/example.env
+++ b/example.env
@@ -7,13 +7,13 @@ POSTGRES_PORT=6500
PORT=8000
CLIENT_ORIGIN=http://localhost:3000
-ACCESS_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCUEFJQkFBSkJBTzVIKytVM0xrWC91SlRvRHhWN01CUURXSTdGU0l0VXNjbGFFKzlaUUg5Q2VpOGIxcUVmCnJxR0hSVDVWUis4c3UxVWtCUVpZTER3MnN3RTVWbjg5c0ZVQ0F3RUFBUUpCQUw4ZjRBMUlDSWEvQ2ZmdWR3TGMKNzRCdCtwOXg0TEZaZXMwdHdtV3Vha3hub3NaV0w4eVpSTUJpRmI4a25VL0hwb3piTnNxMmN1ZU9wKzVWdGRXNApiTlVDSVFENm9JdWxqcHdrZTFGY1VPaldnaXRQSjNnbFBma3NHVFBhdFYwYnJJVVI5d0loQVBOanJ1enB4ckhsCkUxRmJxeGtUNFZ5bWhCOU1HazU0Wk1jWnVjSmZOcjBUQWlFQWhML3UxOVZPdlVBWVd6Wjc3Y3JxMTdWSFBTcXoKUlhsZjd2TnJpdEg1ZGdjQ0lRRHR5QmFPdUxuNDlIOFIvZ2ZEZ1V1cjg3YWl5UHZ1YStxeEpXMzQrb0tFNXdJZwpQbG1KYXZsbW9jUG4rTkVRdGhLcTZuZFVYRGpXTTlTbktQQTVlUDZSUEs0PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQ==
-ACCESS_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZ3d0RRWUpLb1pJaHZjTkFRRUJCUUFEU3dBd1NBSkJBTzVIKytVM0xrWC91SlRvRHhWN01CUURXSTdGU0l0VQpzY2xhRSs5WlFIOUNlaThiMXFFZnJxR0hSVDVWUis4c3UxVWtCUVpZTER3MnN3RTVWbjg5c0ZVQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==
-ACCESS_TOKEN_EXPIRED_IN=15m
-ACCESS_TOKEN_MAXAGE=15
+EMAIL_FROM=admin@admin.com
+SMTP_HOST=smtp.mailtrap.io
+SMTP_USER=90cf952fb44469
+SMTP_PASS=0524531956c552
+SMTP_PORT=587
+TOKEN_EXPIRED_IN=60m
+TOKEN_MAXAGE=60
-REFRESH_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT1FJQkFBSkJBSWFJcXZXeldCSndnYjR1SEhFQ01RdHFZMTI5b2F5RzVZMGlGcG51a0J1VHpRZVlQWkE4Cmx4OC9lTUh3Rys1MlJGR3VxMmE2N084d2s3TDR5dnY5dVY4Q0F3RUFBUUpBRUZ6aEJqOUk3LzAxR285N01CZUgKSlk5TUJLUEMzVHdQQVdwcSswL3p3UmE2ZkZtbXQ5NXNrN21qT3czRzNEZ3M5T2RTeWdsbTlVdndNWXh6SXFERAplUUloQVA5UStrMTBQbGxNd2ZJbDZtdjdTMFRYOGJDUlRaZVI1ZFZZb3FTeW40YmpBaUVBaHVUa2JtZ1NobFlZCnRyclNWZjN0QWZJcWNVUjZ3aDdMOXR5MVlvalZVRlVDSUhzOENlVHkwOWxrbkVTV0dvV09ZUEZVemhyc3Q2Z08KU3dKa2F2VFdKdndEQWlBdWhnVU8yeEFBaXZNdEdwUHVtb3hDam8zNjBMNXg4d012bWdGcEFYNW9uUUlnQzEvSwpNWG1heWtsaFRDeWtXRnpHMHBMWVdkNGRGdTI5M1M2ZUxJUlNIS009Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t
-REFRESH_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZ3d0RRWUpLb1pJaHZjTkFRRUJCUUFEU3dBd1NBSkJBSWFJcXZXeldCSndnYjR1SEhFQ01RdHFZMTI5b2F5Rwo1WTBpRnBudWtCdVR6UWVZUFpBOGx4OC9lTUh3Rys1MlJGR3VxMmE2N084d2s3TDR5dnY5dVY4Q0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==
-REFRESH_TOKEN_EXPIRED_IN=60m
-REFRESH_TOKEN_MAXAGE=60
\ No newline at end of file
+TOKEN_SECRET=my-ultra-secure-json-web-token-string
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 10ab9ec..2fa3182 100644
--- a/go.mod
+++ b/go.mod
@@ -7,8 +7,11 @@ require (
github.com/gin-gonic/gin v1.8.1
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/uuid v1.1.2
+ github.com/k3a/html2text v1.0.8
github.com/spf13/viper v1.12.0
+ github.com/thanhpk/randstr v1.0.4
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
+ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/driver/postgres v1.3.8
gorm.io/gorm v1.23.8
)
@@ -50,6 +53,7 @@ require (
golang.org/x/sys v0.0.0-20220804214406-8e32c043e418 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
+ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.66.4 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum
index c66c66c..5921745 100644
--- a/go.sum
+++ b/go.sum
@@ -153,6 +153,8 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
@@ -214,6 +216,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/k3a/html2text v1.0.8 h1:rVanLhKilpnJUJs/CNKWzMC4YaQINGxK0rSG8ssmnV0=
+github.com/k3a/html2text v1.0.8/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -276,6 +282,10 @@ github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXY
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
@@ -302,6 +312,8 @@ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PK
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/subosito/gotenv v1.3.0 h1:mjC+YW8QpAdXibNi+vNWgzmgBH4+5l5dCXv8cNysBLI=
github.com/subosito/gotenv v1.3.0/go.mod h1:YzJjq/33h7nrwdY+iHMhEOEEbW0ovIz0tB6t6PwAXzs=
+github.com/thanhpk/randstr v1.0.4 h1:IN78qu/bR+My+gHCvMEXhR/i5oriVHcTB/BJJIRTsNo=
+github.com/thanhpk/randstr v1.0.4/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
@@ -497,6 +509,7 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@@ -641,11 +654,15 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
+gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.66.4 h1:SsAcf+mM7mRZo2nJNGt8mZCjG8ZRaNGMURJw7BsIST4=
gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
diff --git a/initializers/loadEnv.go b/initializers/loadEnv.go
index 9bd68fe..0e0f6f7 100644
--- a/initializers/loadEnv.go
+++ b/initializers/loadEnv.go
@@ -16,14 +16,15 @@ type Config struct {
ClientOrigin string `mapstructure:"CLIENT_ORIGIN"`
- AccessTokenPrivateKey string `mapstructure:"ACCESS_TOKEN_PRIVATE_KEY"`
- AccessTokenPublicKey string `mapstructure:"ACCESS_TOKEN_PUBLIC_KEY"`
- RefreshTokenPrivateKey string `mapstructure:"REFRESH_TOKEN_PRIVATE_KEY"`
- RefreshTokenPublicKey string `mapstructure:"REFRESH_TOKEN_PUBLIC_KEY"`
- AccessTokenExpiresIn time.Duration `mapstructure:"ACCESS_TOKEN_EXPIRED_IN"`
- RefreshTokenExpiresIn time.Duration `mapstructure:"REFRESH_TOKEN_EXPIRED_IN"`
- AccessTokenMaxAge int `mapstructure:"ACCESS_TOKEN_MAXAGE"`
- RefreshTokenMaxAge int `mapstructure:"REFRESH_TOKEN_MAXAGE"`
+ TokenSecret string `mapstructure:"TOKEN_SECRET"`
+ TokenExpiresIn time.Duration `mapstructure:"TOKEN_EXPIRED_IN"`
+ TokenMaxAge int `mapstructure:"TOKEN_MAXAGE"`
+
+ EmailFrom string `mapstructure:"EMAIL_FROM"`
+ SMTPHost string `mapstructure:"SMTP_HOST"`
+ SMTPPass string `mapstructure:"SMTP_PASS"`
+ SMTPPort int `mapstructure:"SMTP_PORT"`
+ SMTPUser string `mapstructure:"SMTP_USER"`
}
func LoadConfig(path string) (config Config, err error) {
diff --git a/middleware/deserialize-user.go b/middleware/deserialize-user.go
index 9e108f2..5ff6f79 100644
--- a/middleware/deserialize-user.go
+++ b/middleware/deserialize-user.go
@@ -13,25 +13,25 @@ import (
func DeserializeUser() gin.HandlerFunc {
return func(ctx *gin.Context) {
- var access_token string
- cookie, err := ctx.Cookie("access_token")
+ var token string
+ cookie, err := ctx.Cookie("token")
authorizationHeader := ctx.Request.Header.Get("Authorization")
fields := strings.Fields(authorizationHeader)
if len(fields) != 0 && fields[0] == "Bearer" {
- access_token = fields[1]
+ token = fields[1]
} else if err == nil {
- access_token = cookie
+ token = cookie
}
- if access_token == "" {
+ if token == "" {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "fail", "message": "You are not logged in"})
return
}
config, _ := initializers.LoadConfig(".")
- sub, err := utils.ValidateToken(access_token, config.AccessTokenPublicKey)
+ sub, err := utils.ValidateToken(token, config.TokenSecret)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "fail", "message": err.Error()})
return
diff --git a/migrate/migrate.go b/migrate/migrate.go
index 86e4c3e..72081ce 100644
--- a/migrate/migrate.go
+++ b/migrate/migrate.go
@@ -18,6 +18,7 @@ func init() {
}
func main() {
+ initializers.DB.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")
initializers.DB.AutoMigrate(&models.User{})
fmt.Println("👍 Migration complete")
}
diff --git a/models/user.model.go b/models/user.model.go
index 00665a5..4070f5d 100644
--- a/models/user.model.go
+++ b/models/user.model.go
@@ -7,20 +7,19 @@ import (
)
type User struct {
- ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primary_key"`
- Name string `gorm:"type:varchar(255)"`
- Email string `gorm:"uniqueIndex"`
- Password string
- Role string `gorm:"type:varchar(255)"`
- Provider string
- Photo string
- Verified bool
- CreatedAt time.Time
- UpdatedAt time.Time
-}
-
-func (User) UsersTable() string {
- return "users"
+ ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4();primary_key"`
+ Name string `gorm:"type:varchar(255);not null"`
+ Email string `gorm:"uniqueIndex;not null"`
+ Password string `gorm:"not null"`
+ Role string `gorm:"type:varchar(255);not null"`
+ Provider string `gorm:"not null"`
+ Photo string `gorm:"not null"`
+ VerificationCode string
+ PasswordResetToken string
+ PasswordResetAt time.Time
+ Verified bool `gorm:"not null"`
+ CreatedAt time.Time `gorm:"not null"`
+ UpdatedAt time.Time `gorm:"not null"`
}
type SignUpInput struct {
@@ -46,3 +45,14 @@ type UserResponse struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+
+// 👈 ForgotPasswordInput struct
+type ForgotPasswordInput struct {
+ Email string `json:"email" binding:"required"`
+}
+
+// 👈 ResetPasswordInput struct
+type ResetPasswordInput struct {
+ Password string `json:"password" binding:"required"`
+ PasswordConfirm string `json:"passwordConfirm" binding:"required"`
+}
diff --git a/readMe.md b/readMe.md
index c1c43d0..7fd7a57 100644
--- a/readMe.md
+++ b/readMe.md
@@ -1,5 +1,43 @@
-# Build Golang RESTful API with Gorm, Gin and Postgres
+# Forgot/Reset Passwords in Golang with SMTP HTML Email
+
+This article will teach you how to add a secure forgot/reset password feature to a Golang RESTful API application. We will generate the HTML Email templates with the standard Golang template package and send them via SMTP with the Gomail package.
+
+
+
+## Topics Covered
+
+- Forgot Password and Password Reset Flow
+- Create the Database Models with GORM
+- Create an SMTP Account
+- Setup the HTML Templates
+ - Add the Email Template CSS
+ - Add the Password Reset HTML Template
+- Encoding/Decoding the Password Reset Code
+- Create a Utility Function to Send the Emails
+- Add the Forgot Password Route Handler
+- Add the Reset Password Route Handler
+- Add the API Routes to the Gin Middleware Stack
+
+Read the entire article here: [https://codevoweb.com/forgot-reset-passwords-in-golang-with-html-email](https://codevoweb.com/forgot-reset-passwords-in-golang-with-html-email)
+
+Articles in this series:
### 1. How to Setup Golang GORM RESTful API Project with Postgres
[How to Setup Golang GORM RESTful API Project with Postgres](https://codevoweb.com/setup-golang-gorm-restful-api-project-with-postgres/)
+
+### 2. API with Golang + GORM + PostgreSQL: Access & Refresh Tokens
+
+[API with Golang + GORM + PostgreSQL: Access & Refresh Tokens](https://codevoweb.com/golang-gorm-postgresql-user-registration-with-refresh-tokens)
+
+### 3. Golang and GORM - User Registration and Email Verification
+
+[Golang and GORM - User Registration and Email Verification](https://codevoweb.com/golang-and-gorm-user-registration-email-verification)
+
+### 4. Forgot/Reset Passwords in Golang with SMTP HTML Email
+
+[Forgot/Reset Passwords in Golang with SMTP HTML Email](https://codevoweb.com/forgot-reset-passwords-in-golang-with-html-email)
+
+### 5. Build a RESTful CRUD API with Golang
+
+[Build a RESTful CRUD API with Golang](https://codevoweb.com/build-restful-crud-api-with-golang)
diff --git a/routes/auth.routes.go b/routes/auth.routes.go
index 5c90cdc..11fec7e 100644
--- a/routes/auth.routes.go
+++ b/routes/auth.routes.go
@@ -19,6 +19,8 @@ func (rc *AuthRouteController) AuthRoute(rg *gin.RouterGroup) {
router.POST("/register", rc.authController.SignUpUser)
router.POST("/login", rc.authController.SignInUser)
- router.GET("/refresh", rc.authController.RefreshAccessToken)
router.GET("/logout", middleware.DeserializeUser(), rc.authController.LogoutUser)
+ router.GET("/verifyemail/:verificationCode", rc.authController.VerifyEmail)
+ router.POST("/forgotpassword", rc.authController.ForgotPassword)
+ router.PATCH("/resetpassword/:resetToken", rc.authController.ResetPassword)
}
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..314c963
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,32 @@
+{{define "base"}}
+
+
+
+
+
+ {{template "styles" .}}
+ {{ .Subject}}
+
+
+
+
+ |
+
+
+
+ {{block "content" .}}{{end}}
+
+
+ |
+ |
+
+
+
+
+{{end}}
diff --git a/templates/resetPassword.html b/templates/resetPassword.html
new file mode 100644
index 0000000..dd38d4a
--- /dev/null
+++ b/templates/resetPassword.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+ {{template "styles" .}}
+ {{ .Subject}}
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ Hi {{ .FirstName}},
+
+ Forgot password? Send a PATCH request to with your
+ password and passwordConfirm to {{.URL}}
+
+
+
+ If you didn't forget your password, please ignore this
+ email
+
+ Good luck! Codevo CEO.
+ |
+
+
+ |
+
+
+
+
+
+
+ |
+ |
+
+
+
+
diff --git a/templates/styles.html b/templates/styles.html
new file mode 100644
index 0000000..3a51224
--- /dev/null
+++ b/templates/styles.html
@@ -0,0 +1,331 @@
+{{define "styles"}}
+
+{{end}}
diff --git a/templates/verificationCode.html b/templates/verificationCode.html
new file mode 100644
index 0000000..093b541
--- /dev/null
+++ b/templates/verificationCode.html
@@ -0,0 +1,50 @@
+{{template "base" .}} {{define "content"}}
+
+
+
+
+
+
+
+ Hi {{ .FirstName}},
+ Please verify your account to be able to login
+
+ Good luck! Codevo CEO.
+ |
+
+
+ |
+
+
+
+
+{{end}}
diff --git a/utils/email.go b/utils/email.go
new file mode 100644
index 0000000..496ad35
--- /dev/null
+++ b/utils/email.go
@@ -0,0 +1,84 @@
+package utils
+
+import (
+ "bytes"
+ "crypto/tls"
+ "html/template"
+ "log"
+ "os"
+ "path/filepath"
+
+ "github.com/k3a/html2text"
+ "github.com/wpcodevo/golang-gorm-postgres/initializers"
+ "github.com/wpcodevo/golang-gorm-postgres/models"
+ "gopkg.in/gomail.v2"
+)
+
+type EmailData struct {
+ URL string
+ FirstName string
+ Subject string
+}
+
+// 👇 Email template parser
+
+func ParseTemplateDir(dir string) (*template.Template, error) {
+ var paths []string
+ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ paths = append(paths, path)
+ }
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ return template.ParseFiles(paths...)
+}
+
+func SendEmail(user *models.User, data *EmailData, emailTemp string) {
+ config, err := initializers.LoadConfig(".")
+
+ if err != nil {
+ log.Fatal("could not load config", err)
+ }
+
+ // Sender data.
+ from := config.EmailFrom
+ smtpPass := config.SMTPPass
+ smtpUser := config.SMTPUser
+ to := user.Email
+ smtpHost := config.SMTPHost
+ smtpPort := config.SMTPPort
+
+ var body bytes.Buffer
+
+ template, err := ParseTemplateDir("templates")
+ if err != nil {
+ log.Fatal("Could not parse template", err)
+ }
+
+ template.ExecuteTemplate(&body, emailTemp, &data)
+
+ m := gomail.NewMessage()
+
+ m.SetHeader("From", from)
+ m.SetHeader("To", to)
+ m.SetHeader("Subject", data.Subject)
+ m.SetBody("text/html", body.String())
+ m.AddAlternative("text/plain", html2text.HTML2Text(body.String()))
+
+ d := gomail.NewDialer(smtpHost, smtpPort, smtpUser, smtpPass)
+ d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
+
+ // Send Email
+ if err := d.DialAndSend(m); err != nil {
+ log.Fatal("Could not send email: ", err)
+ }
+
+}
diff --git a/utils/encode.go b/utils/encode.go
new file mode 100644
index 0000000..22a2cd7
--- /dev/null
+++ b/utils/encode.go
@@ -0,0 +1,17 @@
+package utils
+
+import "encoding/base64"
+
+func Encode(s string) string {
+ data := base64.StdEncoding.EncodeToString([]byte(s))
+ return string(data)
+}
+
+func Decode(s string) (string, error) {
+ data, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return "", err
+ }
+
+ return string(data), nil
+}
diff --git a/utils/token.go b/utils/token.go
index 20c9756..6b24bf0 100644
--- a/utils/token.go
+++ b/utils/token.go
@@ -1,67 +1,47 @@
package utils
import (
- "encoding/base64"
"fmt"
"time"
"github.com/golang-jwt/jwt"
)
-func CreateToken(ttl time.Duration, payload interface{}, privateKey string) (string, error) {
- decodedPrivateKey, err := base64.StdEncoding.DecodeString(privateKey)
- if err != nil {
- return "", fmt.Errorf("could not decode key: %w", err)
- }
- key, err := jwt.ParseRSAPrivateKeyFromPEM(decodedPrivateKey)
-
- if err != nil {
- return "", fmt.Errorf("create: parse key: %w", err)
- }
+func GenerateToken(ttl time.Duration, payload interface{}, secretJWTKey string) (string, error) {
+ token := jwt.New(jwt.SigningMethodHS256)
now := time.Now().UTC()
+ claims := token.Claims.(jwt.MapClaims)
- claims := make(jwt.MapClaims)
claims["sub"] = payload
claims["exp"] = now.Add(ttl).Unix()
claims["iat"] = now.Unix()
claims["nbf"] = now.Unix()
- token, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(key)
+ tokenString, err := token.SignedString([]byte(secretJWTKey))
if err != nil {
- return "", fmt.Errorf("create: sign token: %w", err)
+ return "", fmt.Errorf("generating JWT Token failed: %w", err)
}
- return token, nil
+ return tokenString, nil
}
-func ValidateToken(token string, publicKey string) (interface{}, error) {
- decodedPublicKey, err := base64.StdEncoding.DecodeString(publicKey)
- if err != nil {
- return nil, fmt.Errorf("could not decode: %w", err)
- }
-
- key, err := jwt.ParseRSAPublicKeyFromPEM(decodedPublicKey)
-
- if err != nil {
- return "", fmt.Errorf("validate: parse key: %w", err)
- }
-
- parsedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
- if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
- return nil, fmt.Errorf("unexpected method: %s", t.Header["alg"])
+func ValidateToken(token string, signedJWTKey string) (interface{}, error) {
+ tok, err := jwt.Parse(token, func(jwtToken *jwt.Token) (interface{}, error) {
+ if _, ok := jwtToken.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected method: %s", jwtToken.Header["alg"])
}
- return key, nil
- })
+ return []byte(signedJWTKey), nil
+ })
if err != nil {
- return nil, fmt.Errorf("validate: %w", err)
+ return nil, fmt.Errorf("invalidate token: %w", err)
}
- claims, ok := parsedToken.Claims.(jwt.MapClaims)
- if !ok || !parsedToken.Valid {
- return nil, fmt.Errorf("validate: invalid token")
+ claims, ok := tok.Claims.(jwt.MapClaims)
+ if !ok || !tok.Valid {
+ return nil, fmt.Errorf("invalid token claim")
}
return claims["sub"], nil