Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

functional password reset #286

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
1 change: 1 addition & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/huandu/go-assert v1.1.6
github.com/mcnijman/go-emailaddress v1.1.1
github.com/mitchellh/mapstructure v1.5.0
github.com/resend/resend-go/v2 v2.5.0
github.com/spf13/viper v1.18.2
github.com/swaggo/swag v1.16.3
golang.org/x/crypto v0.19.0
Expand Down
2 changes: 2 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ 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/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
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
}
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)
}
4 changes: 3 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,13 @@ 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