Skip to content

Commit

Permalink
functional password reset
Browse files Browse the repository at this point in the history
  • Loading branch information
DOOduneye committed Feb 26, 2024
1 parent b64dcd8 commit 3fecc13
Show file tree
Hide file tree
Showing 24 changed files with 366 additions and 24 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ node_modules
.vscode
.trunk
.env.dev
.env.prod
5 changes: 4 additions & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down
8 changes: 8 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
35 changes: 35 additions & 0 deletions backend/src/auth/email.go
Original file line number Diff line number Diff line change
@@ -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: "[email protected]",
To: []string{"[email protected]"},
Subject: "Password Reset",
Html: fmt.Sprintf("<p>Hello %s, <br> Forgot your password? you can reset your password by clicking on this link: <a href='https://sac.resend.dev/reset-password/%s'>Reset Password</a> You can also copy and paste the following link into your browser: https://sac.resend.dev/reset-password/%s <br><br> If you did not request a password reset, please ignore this email.</p>", name, token, token),
}

_, err := e.Client.Emails.Send(params)
if err != nil {
return err
}

return nil
}
17 changes: 13 additions & 4 deletions backend/src/auth/password.go → backend/src/auth/hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := &params{
memory: 64 * 1024,
iterations: 3,
Expand All @@ -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,
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type Settings struct {
Auth AuthSettings
PineconeSettings PineconeSettings
OpenAISettings OpenAISettings
ResendSettings ResendSettings
}

type intermediateSettings struct {
Expand Down
7 changes: 7 additions & 0 deletions backend/src/config/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
28 changes: 28 additions & 0 deletions backend/src/config/resend.go
Original file line number Diff line number Diff line change
@@ -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
}

Check failure on line 28 in backend/src/config/resend.go

View workflow job for this annotation

GitHub Actions / Lint

File is not `goimports`-ed (goimports)

Check failure on line 28 in backend/src/config/resend.go

View workflow job for this annotation

GitHub Actions / Lint

File is not `goimports`-ed (goimports)
56 changes: 56 additions & 0 deletions backend/src/controllers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
1 change: 1 addition & 0 deletions backend/src/database/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
2 changes: 1 addition & 1 deletion backend/src/database/super.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
20 changes: 20 additions & 0 deletions backend/src/errors/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
)
8 changes: 8 additions & 0 deletions backend/src/errors/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
)
9 changes: 3 additions & 6 deletions backend/src/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions backend/src/models/password_reset.go
Original file line number Diff line number Diff line change
@@ -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"`
}
2 changes: 1 addition & 1 deletion backend/src/models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
3 changes: 2 additions & 1 deletion backend/src/server/routes/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
5 changes: 4 additions & 1 deletion backend/src/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 3fecc13

Please sign in to comment.