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. + +![Forgot/Reset Passwords in Golang with SMTP HTML Email](https://codevoweb.com/wp-content/uploads/2022/08/Forgot-Reset-Passwords-in-Golang-with-SMTP-HTML-Email.webp) + +## 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}} + + + + + + + + + + + +{{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}} + + + + + + + + + + + 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"}} + + + + + + + + +{{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