From 3fecc132ae1483aea82ded84e785c58bdf2dad05 Mon Sep 17 00:00:00 2001 From: David Oduneye Date: Mon, 26 Feb 2024 17:33:48 -0500 Subject: [PATCH] functional password reset --- .gitignore | 1 + backend/go.mod | 5 +- backend/go.sum | 8 ++ backend/src/auth/email.go | 35 ++++++++ backend/src/auth/{password.go => hash.go} | 17 +++- backend/src/config/config.go | 1 + backend/src/config/local.go | 7 ++ backend/src/config/resend.go | 28 +++++++ backend/src/controllers/auth.go | 56 +++++++++++++ backend/src/database/db.go | 1 + backend/src/database/super.go | 2 +- backend/src/errors/auth.go | 20 +++++ backend/src/errors/common.go | 8 ++ backend/src/middleware/auth.go | 9 +-- backend/src/models/password_reset.go | 23 ++++++ backend/src/models/user.go | 2 +- backend/src/server/routes/auth.go | 3 +- backend/src/server/server.go | 5 +- backend/src/services/auth.go | 93 ++++++++++++++++++++-- backend/src/services/user.go | 2 +- backend/src/transactions/password_reset.go | 57 +++++++++++++ backend/tests/api/helpers/auth.go | 2 +- backend/tests/api/user_test.go | 2 +- config/.env.template | 3 +- 24 files changed, 366 insertions(+), 24 deletions(-) create mode 100644 backend/src/auth/email.go rename backend/src/auth/{password.go => hash.go} (81%) create mode 100644 backend/src/config/resend.go create mode 100644 backend/src/models/password_reset.go create mode 100644 backend/src/transactions/password_reset.go diff --git a/.gitignore b/.gitignore index c3f812701..251e8ca55 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules .vscode .trunk .env.dev +.env.prod \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index b6164b1a0..396fdc23d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -16,7 +16,6 @@ require ( github.com/mitchellh/mapstructure v1.5.0 github.com/spf13/viper v1.18.2 github.com/swaggo/swag v1.16.3 - golang.org/x/crypto v0.19.0 golang.org/x/text v0.14.0 gorm.io/driver/postgres v1.5.6 gorm.io/gorm v1.25.7 @@ -25,9 +24,13 @@ require ( require ( github.com/awnumar/memcall v0.2.0 // indirect github.com/awnumar/memguard v0.22.4 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/philhofer/fwd v1.1.2 // indirect + github.com/pquerna/otp v1.4.0 // indirect + github.com/resend/resend-go/v2 v2.5.0 // indirect github.com/tinylib/msgp v1.1.8 // indirect + golang.org/x/term v0.17.0 // indirect ) require ( diff --git a/backend/go.sum b/backend/go.sum index 23bfc4b49..3d27cb3cf 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,6 +6,8 @@ github.com/awnumar/memcall v0.2.0 h1:sRaogqExTOOkkNwO9pzJsL8jrOV29UuUW7teRMfbqtI github.com/awnumar/memcall v0.2.0/go.mod h1:S911igBPR9CThzd/hYQQmTc9SWNu3ZHIlCGaWsWsoJo= github.com/awnumar/memguard v0.22.4 h1:1PLgKcgGPeExPHL8dCOWGVjIbQUBgJv9OL0F/yE1PqQ= github.com/awnumar/memguard v0.22.4/go.mod h1:+APmZGThMBWjnMlKiSM1X7MVpbIVewen2MTkqWkA/zE= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -100,6 +102,10 @@ github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/resend/resend-go/v2 v2.5.0 h1:XzTtzQ9YB2LlGHWjS5AVyUqV9cVbDU+6Z4XgCKsJh4g= +github.com/resend/resend-go/v2 v2.5.0/go.mod h1:ihnxc7wPpSgans8RV8d8dIF4hYWVsqMK5KxXAr9LIos= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= @@ -180,6 +186,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/backend/src/auth/email.go b/backend/src/auth/email.go new file mode 100644 index 000000000..36c55d946 --- /dev/null +++ b/backend/src/auth/email.go @@ -0,0 +1,35 @@ +package auth + +import ( + "fmt" + "os" + + "github.com/resend/resend-go/v2" +) + +type EmailService struct { + Client *resend.Client +} + +func NewEmailService() *EmailService { + client := resend.NewClient(os.Getenv("SAC_RESEND_API_KEY")) + return &EmailService{ + Client: client, + } +} + +func (e *EmailService) SendPasswordResetEmail(name, email, token string) error { + params := &resend.SendEmailRequest{ + From: "onboarding@resend.dev", + To: []string{"generatesac@gmail.com"}, + Subject: "Password Reset", + Html: fmt.Sprintf("

Hello %s,
Forgot your password? you can reset your password by clicking on this link: Reset Password You can also copy and paste the following link into your browser: https://sac.resend.dev/reset-password/%s

If you did not request a password reset, please ignore this email.

", name, token, token), + } + + _, err := e.Client.Emails.Send(params) + if err != nil { + return err + } + + return nil +} diff --git a/backend/src/auth/password.go b/backend/src/auth/hash.go similarity index 81% rename from backend/src/auth/password.go rename to backend/src/auth/hash.go index 852d5c2ed..bb05b9811 100644 --- a/backend/src/auth/password.go +++ b/backend/src/auth/hash.go @@ -19,7 +19,16 @@ type params struct { keyLength uint32 } -func ComputePasswordHash(password string) (*string, error) { +func GeneratePasswordResetToken() (string, error) { + token := make([]byte, 64) + if _, err := rand.Read(token); err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(token), nil +} + +func ComputeHash(data string) (*string, error) { p := ¶ms{ memory: 64 * 1024, iterations: 3, @@ -34,7 +43,7 @@ func ComputePasswordHash(password string) (*string, error) { return nil, err } - hash := argon2.IDKey([]byte(password), + hash := argon2.IDKey([]byte(data), salt, p.iterations, p.memory, @@ -56,13 +65,13 @@ var ( ErrIncompatibleVersion = errors.New("incompatible version of argon2") ) -func ComparePasswordAndHash(password string, encodedHash string) (bool, error) { +func CompareHash(data string, encodedHash string) (bool, error) { p, salt, hash, err := decodeHash(encodedHash) if err != nil { return false, err } - otherHash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength) + otherHash := argon2.IDKey([]byte(data), salt, p.iterations, p.memory, p.parallelism, p.keyLength) if subtle.ConstantTimeCompare(hash, otherHash) == 1 { return true, nil diff --git a/backend/src/config/config.go b/backend/src/config/config.go index bef5661ef..e58e3ba5e 100644 --- a/backend/src/config/config.go +++ b/backend/src/config/config.go @@ -13,6 +13,7 @@ type Settings struct { Auth AuthSettings PineconeSettings PineconeSettings OpenAISettings OpenAISettings + ResendSettings ResendSettings } type intermediateSettings struct { diff --git a/backend/src/config/local.go b/backend/src/config/local.go index ad2f458f0..f5214c8a4 100644 --- a/backend/src/config/local.go +++ b/backend/src/config/local.go @@ -51,5 +51,12 @@ func readLocal(v *viper.Viper, path string, useDevDotEnv bool) (*Settings, error settings.OpenAISettings = *openAISettings + resendSettings, err := readResendSettings() + if err != nil { + return nil, fmt.Errorf("failed to read Resend settings: %w", err) + } + + settings.ResendSettings = *resendSettings + return settings, nil } diff --git a/backend/src/config/resend.go b/backend/src/config/resend.go new file mode 100644 index 000000000..7feef66d5 --- /dev/null +++ b/backend/src/config/resend.go @@ -0,0 +1,28 @@ +package config + +import ( + "errors" + "os" + + m "github.com/garrettladley/mattress" +) + +type ResendSettings struct { + APIKey *m.Secret[string] +} + +func readResendSettings() (*ResendSettings, error) { + apiKey := os.Getenv("SAC_RESEND_API_KEY") + if apiKey == "" { + return nil, errors.New("SAC_RESEND_API_KEY is not set") + } + + secretAPIKey, err := m.NewSecret(apiKey) + if err != nil { + return nil, errors.New("failed to create secret from api key") + } + + return &ResendSettings{ + APIKey: secretAPIKey, + }, nil +} \ No newline at end of file diff --git a/backend/src/controllers/auth.go b/backend/src/controllers/auth.go index 713f39303..b68c391d7 100644 --- a/backend/src/controllers/auth.go +++ b/backend/src/controllers/auth.go @@ -181,3 +181,59 @@ func (a *AuthController) UpdatePassword(c *fiber.Ctx) error { return utilities.FiberMessage(c, fiber.StatusOK, "success") } + +// ForgotPassword godoc +// +// @Summary Generates a password reset token +// @Description Generates a password reset token +// @ID forgot-password +// @Tags auth +// @Accept json +// @Produce json +// @Param userBody body models.PasswordResetRequestBody true "User Body" +// @Success 200 {object} utilities.SuccessResponse +// @Failure 400 {object} errors.Error +// @Failure 429 {object} errors.Error +// @Failure 500 {object} errors.Error +// @Router /auth/forgot-password [post] +func (a *AuthController) ForgotPassword(c *fiber.Ctx) error { + var userBody models.PasswordResetRequestBody + + if err := c.BodyParser(&userBody); err != nil { + return errors.FailedToParseRequestBody.FiberError(c) + } + + if err := a.authService.ForgotPassword(userBody); err != nil { + return err.FiberError(c) + } + + return utilities.FiberMessage(c, fiber.StatusOK, "success") +} + +// VerifyPasswordResetToken godoc +// +// @Summary Verifies a password reset token +// @Description Verifies a password reset token +// @ID verify-password-reset-token +// @Tags auth +// @Accept json +// @Produce json +// @Param tokenBody body models.VerifyPasswordResetTokenRequestBody true "Token Body" +// @Success 200 {object} utilities.SuccessResponse +// @Failure 400 {object} errors.Error +// @Failure 429 {object} errors.Error +// @Failure 500 {object} errors.Error +// @Router /auth/verify-reset [post] +func (a *AuthController) VerifyPasswordResetToken(c *fiber.Ctx) error { + var tokenBody models.VerifyPasswordResetTokenRequestBody + + if err := c.BodyParser(&tokenBody); err != nil { + return errors.FailedToParseRequestBody.FiberError(c) + } + + if err := a.authService.VerifyPasswordResetToken(tokenBody); err != nil { + return err.FiberError(c) + } + + return utilities.FiberMessage(c, fiber.StatusOK, "success") +} diff --git a/backend/src/database/db.go b/backend/src/database/db.go index 4466df11f..d4f06e0ed 100644 --- a/backend/src/database/db.go +++ b/backend/src/database/db.go @@ -76,6 +76,7 @@ func MigrateDB(settings config.Settings, db *gorm.DB) error { &models.PointOfContact{}, &models.Tag{}, &models.User{}, + &models.PasswordReset{}, &models.Series{}, &models.EventInstanceException{}, &models.EventSeries{}, diff --git a/backend/src/database/super.go b/backend/src/database/super.go index 93c065b66..43af7be9d 100644 --- a/backend/src/database/super.go +++ b/backend/src/database/super.go @@ -11,7 +11,7 @@ import ( var SuperUserUUID uuid.UUID func SuperUser(superUserSettings config.SuperUserSettings) (*models.User, *errors.Error) { - passwordHash, err := auth.ComputePasswordHash(superUserSettings.Password.Expose()) + passwordHash, err := auth.ComputeHash(superUserSettings.Password.Expose()) if err != nil { return nil, &errors.FailedToComputePasswordHash } diff --git a/backend/src/errors/auth.go b/backend/src/errors/auth.go index e53e5f03d..7a164337e 100644 --- a/backend/src/errors/auth.go +++ b/backend/src/errors/auth.go @@ -11,4 +11,24 @@ var ( StatusCode: fiber.StatusInternalServerError, Message: "failed to cast to custom claims", } + FailedToCreatePasswordReset = Error{ + StatusCode: fiber.StatusInternalServerError, + Message: "failed to create password reset", + } + FailedToDeletePasswordReset = Error{ + StatusCode: fiber.StatusInternalServerError, + Message: "failed to delete password reset", + } + TokenExpired = Error{ + StatusCode: fiber.StatusUnauthorized, + Message: "token expired", + } + FailedToGetPasswordResetToken = Error{ + StatusCode: fiber.StatusInternalServerError, + Message: "failed to get password reset token", + } + PasswordResetTokenNotFound = Error{ + StatusCode: fiber.StatusNotFound, + Message: "password reset token not found", + } ) diff --git a/backend/src/errors/common.go b/backend/src/errors/common.go index 5b93b0b14..8b67ef563 100644 --- a/backend/src/errors/common.go +++ b/backend/src/errors/common.go @@ -67,4 +67,12 @@ var ( StatusCode: fiber.StatusUnauthorized, Message: "failed to validate access token", } + FailedToGenerateToken = Error{ + StatusCode: fiber.StatusInternalServerError, + Message: "failed to generate token", + } + FailedToSendEmail = Error{ + StatusCode: fiber.StatusInternalServerError, + Message: "failed to send email", + } ) diff --git a/backend/src/middleware/auth.go b/backend/src/middleware/auth.go index 93a07b5f6..35108cbbf 100644 --- a/backend/src/middleware/auth.go +++ b/backend/src/middleware/auth.go @@ -18,12 +18,9 @@ var paths = []string{ "/api/v1/auth/refresh", "/api/v1/users/", "/api/v1/auth/logout", -} - -func (m *AuthMiddlewareService) DisableAuth(h fiber.Handler) fiber.Handler { - return func(c *fiber.Ctx) error { - return h(c) - } + "/api/v1/auth/forgot-password", + "/api/v1/auth/verify-reset", + "/api/v1/auth/verify-email", } func (m *AuthMiddlewareService) IsSuper(c *fiber.Ctx) bool { diff --git a/backend/src/models/password_reset.go b/backend/src/models/password_reset.go new file mode 100644 index 000000000..efc35032d --- /dev/null +++ b/backend/src/models/password_reset.go @@ -0,0 +1,23 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type PasswordReset struct { + UserID uuid.UUID `gorm:"type:varchar(36);not null;primaryKey" json:"user_id" validate:"required,uuid4"` + Token string `gorm:"type:varchar(255);unique" json:"token" validate:"required,max=255"` + ExpiresAt time.Time `gorm:"type:timestamp;not null;primaryKey" json:"expires_at" validate:"required"` +} + +type PasswordResetRequestBody struct { + Email string `json:"email" validate:"required,email"` +} + +type VerifyPasswordResetTokenRequestBody struct { + Token string `json:"token" validate:"required"` + NewPassword string `json:"new_password" validate:"required,min=8,password"` + VerifyNewPassword string `json:"verify_new_password" validate:"required,min=8,password,eqfield=NewPassword"` +} diff --git a/backend/src/models/user.go b/backend/src/models/user.go index 6371b393f..fa9ed635c 100644 --- a/backend/src/models/user.go +++ b/backend/src/models/user.go @@ -64,7 +64,7 @@ type CreateUserRequestBody struct { FirstName string `json:"first_name" validate:"required,max=255"` LastName string `json:"last_name" validate:"required,max=255"` Email string `json:"email" validate:"required,email,neu_email,max=255"` - Password string `json:"password" validate:"required,password"` + Password string `json:"password" validate:"required,password,min=8,max=255"` College College `json:"college" validate:"required,oneof=CAMD DMSB KCCS CE BCHS SL CPS CS CSSH"` Year Year `json:"year" validate:"required,min=1,max=6"` } diff --git a/backend/src/server/routes/auth.go b/backend/src/server/routes/auth.go index fd866caff..948e0c3ad 100644 --- a/backend/src/server/routes/auth.go +++ b/backend/src/server/routes/auth.go @@ -21,5 +21,6 @@ func Auth(router fiber.Router, authService services.AuthServiceInterface, settin auth.Get("/refresh", authController.Refresh) auth.Get("/me", authController.Me) auth.Post("/update-password/:userID", authMiddleware.Limiter(2, 1*time.Minute), authMiddleware.UserAuthorizeById, authController.UpdatePassword) - // auth.Post("/reset-password/:userID", middleware.Skip(authMiddleware.UserAuthorizeById), authController.ResetPassword) + auth.Post("/forgot-password", authController.ForgotPassword) + auth.Post("/verify-reset", authController.VerifyPasswordResetToken) } diff --git a/backend/src/server/server.go b/backend/src/server/server.go index 278cbf3d6..99a224fa6 100644 --- a/backend/src/server/server.go +++ b/backend/src/server/server.go @@ -3,6 +3,7 @@ package server import ( "fmt" + "github.com/GenerateNU/sac/backend/src/auth" "github.com/GenerateNU/sac/backend/src/config" "github.com/GenerateNU/sac/backend/src/middleware" "github.com/GenerateNU/sac/backend/src/server/routes" @@ -35,12 +36,14 @@ func Init(db *gorm.DB, settings config.Settings) *fiber.App { } authMiddleware := middleware.NewAuthAuthMiddlewareService(db, validate, settings.Auth) + emailService := auth.NewEmailService() + apiv1 := app.Group("/api/v1") apiv1.Use(authMiddleware.Authenticate) routes.Utility(app) - routes.Auth(apiv1, services.NewAuthService(db, validate), settings.Auth, authMiddleware) + routes.Auth(apiv1, services.NewAuthService(db, validate, emailService), settings.Auth, authMiddleware) routes.UserRoutes(apiv1, db, validate, authMiddleware) routes.Contact(apiv1, services.NewContactService(db, validate), authMiddleware) routes.ClubRoutes(apiv1, db, validate, authMiddleware) diff --git a/backend/src/services/auth.go b/backend/src/services/auth.go index 176312c07..260465ce9 100644 --- a/backend/src/services/auth.go +++ b/backend/src/services/auth.go @@ -1,6 +1,8 @@ package services import ( + "time" + "github.com/GenerateNU/sac/backend/src/auth" "github.com/GenerateNU/sac/backend/src/errors" "github.com/GenerateNU/sac/backend/src/models" @@ -15,17 +17,21 @@ type AuthServiceInterface interface { Me(id string) (*models.User, *errors.Error) Login(userBody models.LoginUserResponseBody) (*models.User, *errors.Error) UpdatePassword(id string, passwordBody models.UpdatePasswordRequestBody) *errors.Error + ForgotPassword(userBody models.PasswordResetRequestBody) *errors.Error + VerifyPasswordResetToken(passwordBody models.VerifyPasswordResetTokenRequestBody) *errors.Error } type AuthService struct { DB *gorm.DB Validate *validator.Validate + Email *auth.EmailService } -func NewAuthService(db *gorm.DB, validate *validator.Validate) *AuthService { +func NewAuthService(db *gorm.DB, validate *validator.Validate, email *auth.EmailService) *AuthService { return &AuthService{ DB: db, Validate: validate, + Email: email, } } @@ -53,7 +59,7 @@ func (a *AuthService) Login(userBody models.LoginUserResponseBody) (*models.User return nil, err } - correct, passwordErr := auth.ComparePasswordAndHash(userBody.Password, user.PasswordHash) + correct, passwordErr := auth.CompareHash(userBody.Password, user.PasswordHash) if passwordErr != nil || !correct { return nil, &errors.FailedToValidateUser } @@ -83,7 +89,6 @@ func (a *AuthService) UpdatePassword(id string, passwordBody models.UpdatePasswo return idErr } - // TODO: Validate password if err := a.Validate.Struct(passwordBody); err != nil { return &errors.FailedToValidateUser } @@ -93,12 +98,12 @@ func (a *AuthService) UpdatePassword(id string, passwordBody models.UpdatePasswo return err } - correct, passwordErr := auth.ComparePasswordAndHash(passwordBody.OldPassword, passwordHash) + correct, passwordErr := auth.CompareHash(passwordBody.OldPassword, passwordHash) if passwordErr != nil || !correct { return &errors.FailedToValidateUser } - hash, hashErr := auth.ComputePasswordHash(passwordBody.NewPassword) + hash, hashErr := auth.ComputeHash(passwordBody.NewPassword) if hashErr != nil { return &errors.FailedToValidateUser } @@ -110,3 +115,81 @@ func (a *AuthService) UpdatePassword(id string, passwordBody models.UpdatePasswo return nil } + +func (a *AuthService) ForgotPassword(userBody models.PasswordResetRequestBody) *errors.Error { + if err := a.Validate.Struct(userBody); err != nil { + return &errors.FailedToValidateUser + } + + user, err := transactions.GetUserByEmail(a.DB, userBody.Email) + if err != nil { + return nil // Do not return error if user does not exist + } + + // check if user has a password reset token, if not, generate one, if yes, use the existing one + activeToken, tokenErr := transactions.GetActivePasswordResetTokenByUserID(a.DB, user.ID) + if tokenErr != nil { + if tokenErr != &errors.PasswordResetTokenNotFound { + return tokenErr + } + } + + if activeToken != nil { + return nil + } + + token, generateErr := auth.GeneratePasswordResetToken() + if generateErr != nil { + return &errors.FailedToGenerateToken + } + + // save token to db + saveErr := transactions.SavePasswordResetToken(a.DB, user.ID, token) + if saveErr != nil { + return saveErr + } + + // PLEASE NOTE: don't overuse this email service in testing (we only have 1000 free emails per month) + sendErr := a.Email.SendPasswordResetEmail(user.FirstName, user.Email, token) + if sendErr != nil { + deleteErr := transactions.DeletePasswordResetToken(a.DB, token) + if deleteErr != nil { + return deleteErr + } + return &errors.FailedToSendEmail + } + + return nil +} + +func (a *AuthService) VerifyPasswordResetToken(passwordBody models.VerifyPasswordResetTokenRequestBody) *errors.Error { + if err := a.Validate.Struct(passwordBody); err != nil { + return &errors.FailedToValidateUser + } + + token, tokenErr := transactions.GetPasswordResetToken(a.DB, passwordBody.Token) + if tokenErr != nil { + return tokenErr + } + + if token.ExpiresAt.Before(time.Now().UTC()) { + return &errors.TokenExpired + } + + hash, hashErr := auth.ComputeHash(passwordBody.NewPassword) + if hashErr != nil { + return &errors.FailedToValidateUser + } + + updateErr := transactions.UpdatePassword(a.DB, token.UserID, *hash) + if updateErr != nil { + return updateErr + } + + deleteErr := transactions.DeletePasswordResetToken(a.DB, passwordBody.Token) + if deleteErr != nil { + return deleteErr + } + + return nil +} diff --git a/backend/src/services/user.go b/backend/src/services/user.go index a940a6b33..579a935ed 100644 --- a/backend/src/services/user.go +++ b/backend/src/services/user.go @@ -40,7 +40,7 @@ func (u *UserService) CreateUser(userBody models.CreateUserRequestBody) (*models return nil, &errors.FailedToMapRequestToModel } - passwordHash, err := auth.ComputePasswordHash(userBody.Password) + passwordHash, err := auth.ComputeHash(userBody.Password) if err != nil { return nil, &errors.FailedToComputePasswordHash } diff --git a/backend/src/transactions/password_reset.go b/backend/src/transactions/password_reset.go new file mode 100644 index 000000000..229e42a8f --- /dev/null +++ b/backend/src/transactions/password_reset.go @@ -0,0 +1,57 @@ +package transactions + +import ( + "time" + + "github.com/GenerateNU/sac/backend/src/errors" + "github.com/GenerateNU/sac/backend/src/models" + "github.com/google/uuid" + "gorm.io/gorm" +) + +func SavePasswordResetToken(db *gorm.DB, userID uuid.UUID, token string) *errors.Error { + passwordReset := models.PasswordReset{ + UserID: userID, + Token: token, + ExpiresAt: time.Now().Add(time.Hour * 24).UTC(), + } + + if err := db.Create(&passwordReset).Error; err != nil { + return &errors.FailedToCreatePasswordReset + } + + return nil +} + +func DeletePasswordResetToken(db *gorm.DB, token string) *errors.Error { + if err := db.Where("token = ?", token).Delete(&models.PasswordReset{}).Error; err != nil { + return &errors.FailedToDeletePasswordReset + } + + return nil +} + +func GetPasswordResetToken(db *gorm.DB, token string) (*models.PasswordReset, *errors.Error) { + passwordReset := models.PasswordReset{} + if err := db.Where("token = ?", token).First(&passwordReset).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, &errors.PasswordResetTokenNotFound + } + return nil, &errors.FailedToGetPasswordResetToken + } + + return &passwordReset, nil +} + + +func GetActivePasswordResetTokenByUserID(db *gorm.DB, userID uuid.UUID) (*models.PasswordReset, *errors.Error) { + passwordReset := models.PasswordReset{} + if err := db.Where("user_id = ? AND expires_at > ?", userID, time.Now().UTC()).First(&passwordReset).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, &errors.PasswordResetTokenNotFound + } + return nil, &errors.FailedToGetPasswordResetToken + } + + return &passwordReset, nil +} \ No newline at end of file diff --git a/backend/tests/api/helpers/auth.go b/backend/tests/api/helpers/auth.go index eb65640e3..c64a981d5 100644 --- a/backend/tests/api/helpers/auth.go +++ b/backend/tests/api/helpers/auth.go @@ -133,7 +133,7 @@ func (app *TestApp) authStudent() { func SampleStudentFactory() (models.User, string) { password := "1234567890&" - hashedPassword, err := auth.ComputePasswordHash(password) + hashedPassword, err := auth.ComputeHash(password) if err != nil { panic(err) } diff --git a/backend/tests/api/user_test.go b/backend/tests/api/user_test.go index 20e1d0ebe..507015c28 100644 --- a/backend/tests/api/user_test.go +++ b/backend/tests/api/user_test.go @@ -373,7 +373,7 @@ func AssertUserWithIDBodyRespDB(eaa h.ExistingAppAssert, resp *http.Response, bo eaa.Assert.Equal(dbUser.College, respUser.College) eaa.Assert.Equal(dbUser.Year, respUser.Year) - match, err := auth.ComparePasswordAndHash((*body)["password"].(string), dbUser.PasswordHash) + match, err := auth.CompareHash((*body)["password"].(string), dbUser.PasswordHash) eaa.Assert.NilError(err) diff --git a/config/.env.template b/config/.env.template index 977a98269..9bc755042 100644 --- a/config/.env.template +++ b/config/.env.template @@ -1,3 +1,4 @@ SAC_PINECONE_INDEX_HOST="https://SAC_PINECONE_INDEX_HOST.com" SAC_PINECONE_API_KEY="SAC_PINECONE_API_KEY" -SAC_OPENAI_API_KEY="SAC_OPENAI_API_KEY" \ No newline at end of file +SAC_OPENAI_API_KEY="SAC_OPENAI_API_KEY" +SAC_RESEND_API_KEY="SAC_RESEND_API_KEY" \ No newline at end of file