Kaynağa Gözat

reset password backend

Alexander Belanger 5 yıl önce
ebeveyn
işleme
f0a9bac5c7

+ 1 - 0
cmd/app/main.go

@@ -61,6 +61,7 @@ func main() {
 		&models.Invite{},
 		&models.AuthCode{},
 		&models.DNSRecord{},
+		&models.PWResetToken{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 1 - 0
cmd/migrate/main.go

@@ -46,6 +46,7 @@ func main() {
 		&models.Invite{},
 		&models.AuthCode{},
 		&models.DNSRecord{},
+		&models.PWResetToken{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 2 - 0
go.mod

@@ -57,6 +57,8 @@ require (
 	github.com/pelletier/go-toml v1.8.1 // indirect
 	github.com/pkg/errors v0.9.1
 	github.com/rs/zerolog v1.20.0
+	github.com/sendgrid/rest v2.6.3+incompatible // indirect
+	github.com/sendgrid/sendgrid-go v3.8.0+incompatible // indirect
 	github.com/sirupsen/logrus v1.7.0
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/viper v1.4.0

+ 6 - 0
go.sum

@@ -926,6 +926,12 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
 github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/sendgrid/rest v1.0.2 h1:xdfALkR1m9eqf41/zEnUmV0fw4b31ZzGZ4Dj5f2/w04=
+github.com/sendgrid/rest v2.6.3+incompatible h1:h/uruXAzKxVyDDIQX/MkQI73p/gsdpEnb5q2wxSvTsA=
+github.com/sendgrid/rest v2.6.3+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
+github.com/sendgrid/sendgrid-go v1.2.0 h1:2K3teZdhaPe12ftFyFL4AWDH4QmNPc+sCi6mWFx5+oo=
+github.com/sendgrid/sendgrid-go v3.8.0+incompatible h1:7yoUFMwT+jDI2ArBpC6zvtuQj1RUyYfCDl7zZea3XV4=
+github.com/sendgrid/sendgrid-go v3.8.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=

+ 4 - 0
internal/config/config.go

@@ -36,6 +36,10 @@ type ServerConf struct {
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 
+	SendgridAPIKey            string `env:"SENDGRID_API_KEY"`
+	SendgridPWResetTemplateID string `env:"SENDGRID_PW_RESET_TEMPLATE_ID"`
+	SendgridSenderEmail       string `env:"SENDGRID_SENDER_EMAIL"`
+
 	DOClientID          string `env:"DO_CLIENT_ID"`
 	DOClientSecret      string `env:"DO_CLIENT_SECRET"`
 	ProvisionerImageTag string `env:"PROV_IMAGE_TAG,default=latest"`

+ 39 - 0
internal/forms/user.go

@@ -1,6 +1,8 @@
 package forms
 
 import (
+	"time"
+
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/crypto/bcrypt"
@@ -70,3 +72,40 @@ func (uuf *DeleteUserForm) ToUser(_ repository.UserRepository) (*models.User, er
 		},
 	}, nil
 }
+
+// InitiateResetUserPasswordForm represents the accepted values for resetting a user's password
+type InitiateResetUserPasswordForm struct {
+	Email string `json:"email" form:"required"`
+}
+
+func (ruf *InitiateResetUserPasswordForm) ToPWResetToken() (*models.PWResetToken, string, error) {
+	expiry := time.Now().Add(30 * time.Minute)
+
+	rawToken := stringWithCharset(32, randCharset)
+
+	hashedToken, err := bcrypt.GenerateFromPassword([]byte(rawToken), 8)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	return &models.PWResetToken{
+		Email:   ruf.Email,
+		IsValid: true,
+		Expiry:  &expiry,
+		Token:   string(hashedToken),
+	}, rawToken, nil
+}
+
+type VerifyResetUserPasswordForm struct {
+	Email          string `json:"email" form:"required,max=255,email"`
+	PWResetTokenID uint   `json:"token_id" form:"required"`
+	Token          string `json:"token" form:"required"`
+}
+
+type FinalizeResetUserPasswordForm struct {
+	Email          string `json:"email" form:"required,max=255,email"`
+	PWResetTokenID uint   `json:"token_id" form:"required"`
+	Token          string `json:"token" form:"required"`
+	NewPassword    string `json:"new_password" form:"required,max=255"`
+}

+ 46 - 0
internal/integrations/email/sendgrid.go

@@ -0,0 +1,46 @@
+package email
+
+import (
+	"os"
+
+	"github.com/sendgrid/sendgrid-go"
+	"github.com/sendgrid/sendgrid-go/helpers/mail"
+)
+
+type SendgridClient struct {
+	APIKey            string
+	PWResetTemplateID string
+	SenderEmail       string
+}
+
+func (client *SendgridClient) SendPWResetEmail(url, email string) error {
+	request := sendgrid.GetRequest(os.Getenv("SENDGRID_API_KEY"), "/v3/mail/send", "https://api.sendgrid.com")
+	request.Method = "POST"
+
+	sgMail := &mail.SGMailV3{
+		Personalizations: []*mail.Personalization{
+			{
+				To: []*mail.Email{
+					{
+						Address: email,
+					},
+				},
+				DynamicTemplateData: map[string]interface{}{
+					"url":   url,
+					"email": email,
+				},
+			},
+		},
+		From: &mail.Email{
+			Address: client.SenderEmail,
+			Name:    "Porter",
+		},
+		TemplateID: client.PWResetTemplateID,
+	}
+
+	request.Body = mail.GetRequestBody(sgMail)
+
+	_, err := sendgrid.API(request)
+
+	return err
+}

+ 24 - 0
internal/models/pw_reset_token.go

@@ -0,0 +1,24 @@
+package models
+
+import (
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// PWResetToken type that extends gorm.Model
+type PWResetToken struct {
+	gorm.Model
+
+	Email   string
+	IsValid bool
+	Expiry  *time.Time
+
+	// Token is hashed like a password before storage
+	Token string
+}
+
+func (p *PWResetToken) IsExpired() bool {
+	timeLeft := p.Expiry.Sub(time.Now())
+	return timeLeft < 0
+}

+ 0 - 0
internal/repository/gorm/porter_list_clusters.db-journal


+ 49 - 0
internal/repository/gorm/pw_reset_token.go

@@ -0,0 +1,49 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// PWResetTokenRepository uses gorm.DB for querying the database
+type PWResetTokenRepository struct {
+	db *gorm.DB
+}
+
+// NewPWResetTokenRepository returns a PWResetTokenRepository which uses
+// gorm.DB for querying the database
+func NewPWResetTokenRepository(db *gorm.DB) repository.PWResetTokenRepository {
+	return &PWResetTokenRepository{db}
+}
+
+// CreatePWResetToken creates a new auth code
+func (repo *PWResetTokenRepository) CreatePWResetToken(a *models.PWResetToken) (*models.PWResetToken, error) {
+	if err := repo.db.Create(a).Error; err != nil {
+		return nil, err
+	}
+	return a, nil
+}
+
+// ReadPWResetToken gets an invite specified by a unique token
+func (repo *PWResetTokenRepository) ReadPWResetToken(id uint) (*models.PWResetToken, error) {
+
+	pwReset := &models.PWResetToken{}
+
+	if err := repo.db.Where("id = ?", id).First(&pwReset).Error; err != nil {
+		return nil, err
+	}
+
+	return pwReset, nil
+}
+
+// UpdatePWResetToken modifies an existing PWResetToken in the database
+func (repo *PWResetTokenRepository) UpdatePWResetToken(
+	pwToken *models.PWResetToken,
+) (*models.PWResetToken, error) {
+	if err := repo.db.Save(pwToken).Error; err != nil {
+		return nil, err
+	}
+
+	return pwToken, nil
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -22,6 +22,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		Invite:           NewInviteRepository(db),
 		AuthCode:         NewAuthCodeRepository(db),
 		DNSRecord:        NewDNSRecordRepository(db),
+		PWResetToken:     NewPWResetTokenRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 65 - 0
internal/repository/memory/pw_reset_token.go

@@ -0,0 +1,65 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// PWResetTokenRepository uses gorm.DB for querying the database
+type PWResetTokenRepository struct {
+	canQuery      bool
+	pwResetTokens []*models.PWResetToken
+}
+
+// NewPWResetTokenRepository returns a PWResetTokenRepository which uses
+// gorm.DB for querying the database
+func NewPWResetTokenRepository(canQuery bool) repository.PWResetTokenRepository {
+	return &PWResetTokenRepository{canQuery, []*models.PWResetToken{}}
+}
+
+// CreatePWResetToken creates a new invite
+func (repo *PWResetTokenRepository) CreatePWResetToken(a *models.PWResetToken) (*models.PWResetToken, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.pwResetTokens = append(repo.pwResetTokens, a)
+	a.ID = uint(len(repo.pwResetTokens))
+
+	return a, nil
+}
+
+// ReadPWResetToken gets an auth code object specified by the unique code
+func (repo *PWResetTokenRepository) ReadPWResetToken(id uint) (*models.PWResetToken, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.pwResetTokens) || repo.pwResetTokens[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.pwResetTokens[index], nil
+}
+
+// UpdatePWResetToken modifies an existing PWResetToken in the database
+func (repo *PWResetTokenRepository) UpdatePWResetToken(
+	pwToken *models.PWResetToken,
+) (*models.PWResetToken, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(pwToken.ID-1) >= len(repo.pwResetTokens) || repo.pwResetTokens[pwToken.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(pwToken.ID - 1)
+	repo.pwResetTokens[index] = pwToken
+
+	return pwToken, nil
+}

+ 1 - 0
internal/repository/memory/repository.go

@@ -18,6 +18,7 @@ func NewRepository(canQuery bool) *repository.Repository {
 		Invite:           NewInviteRepository(canQuery),
 		AuthCode:         NewAuthCodeRepository(canQuery),
 		DNSRecord:        NewDNSRecordRepository(canQuery),
+		PWResetToken:     NewPWResetTokenRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
 		BasicIntegration: NewBasicIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),

+ 12 - 0
internal/repository/pw_reset_token.go

@@ -0,0 +1,12 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// PWResetTokenRepository represents the set of queries on the PWResetToken model
+type PWResetTokenRepository interface {
+	CreatePWResetToken(pwToken *models.PWResetToken) (*models.PWResetToken, error)
+	ReadPWResetToken(id uint) (*models.PWResetToken, error)
+	UpdatePWResetToken(pwToken *models.PWResetToken) (*models.PWResetToken, error)
+}

+ 1 - 0
internal/repository/repository.go

@@ -15,6 +15,7 @@ type Repository struct {
 	Invite           InviteRepository
 	AuthCode         AuthCodeRepository
 	DNSRecord        DNSRecordRepository
+	PWResetToken     PWResetTokenRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 196 - 0
server/api/user_handler.go

@@ -18,6 +18,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/integrations/email"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 )
@@ -353,6 +354,201 @@ func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// InitiatePWResetUser initiates the password reset flow based on an email. The endpoint
+// checks if the email exists, but returns a 200 status code regardless, since we don't
+// want to leak in-use emails
+func (app *App) InitiatePWResetUser(w http.ResponseWriter, r *http.Request) {
+	form := &forms.InitiateResetUserPasswordForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// check that the email exists; return 200 status code even if it doesn't
+	_, err := app.Repo.User.ReadUserByEmail(form.Email)
+
+	if err == gorm.ErrRecordNotFound {
+		w.WriteHeader(http.StatusOK)
+		return
+	} else if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// convert the form to a project model
+	pwReset, rawToken, err := form.ToPWResetToken()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	pwReset, err = app.Repo.PWResetToken.CreatePWResetToken(pwReset)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	queryVals := url.Values{
+		"token": []string{rawToken},
+		"email": []string{form.Email},
+	}
+
+	sgClient := email.SendgridClient{
+		APIKey:            app.ServerConf.SendgridAPIKey,
+		PWResetTemplateID: app.ServerConf.SendgridPWResetTemplateID,
+		SenderEmail:       app.ServerConf.SendgridSenderEmail,
+	}
+
+	err = sgClient.SendPWResetEmail(
+		fmt.Sprintf("https://%s/auth/reset?%s", app.ServerConf.ServerURL, queryVals.Encode()),
+		form.Email,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// VerifyPWResetUser makes sure that the token is correct and still valid
+func (app *App) VerifyPWResetUser(w http.ResponseWriter, r *http.Request) {
+	form := &forms.VerifyResetUserPasswordForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	token, err := app.Repo.PWResetToken.ReadPWResetToken(form.PWResetTokenID)
+
+	if err != nil {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// make sure the token is still valid and has not expired
+	if !token.IsValid || token.IsExpired() {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// check that the email matches
+	if token.Email != form.Email {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// make sure the token is correct
+	if err := bcrypt.CompareHashAndPassword([]byte(token.Token), []byte(form.Token)); err != nil {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// FinalizPWResetUser completes the password reset flow based on an email.
+func (app *App) FinalizPWResetUser(w http.ResponseWriter, r *http.Request) {
+	form := &forms.FinalizeResetUserPasswordForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// verify the token is valid
+	token, err := app.Repo.PWResetToken.ReadPWResetToken(form.PWResetTokenID)
+
+	if err != nil {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// make sure the token is still valid and has not expired
+	if !token.IsValid || token.IsExpired() {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// check that the email matches
+	if token.Email != form.Email {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// make sure the token is correct
+	if err := bcrypt.CompareHashAndPassword([]byte(token.Token), []byte(form.Token)); err != nil {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	// check that the email exists
+	user, err := app.Repo.User.ReadUserByEmail(form.Email)
+
+	if err != nil {
+		w.WriteHeader(http.StatusForbidden)
+		return
+	}
+
+	hashedPW, err := bcrypt.GenerateFromPassword([]byte(user.Password), 8)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	user.Password = string(hashedPW)
+
+	user, err = app.Repo.User.UpdateUser(user)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// invalidate the token
+	token.IsValid = false
+
+	_, err = app.Repo.PWResetToken.UpdatePWResetToken(token)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // ------------------------ User handler helper functions ------------------------ //
 
 // writeUser will take a POST or PUT request to the /api/users endpoint and decode

+ 18 - 0
server/router/router.go

@@ -98,6 +98,24 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"POST",
+			"/password/reset/initiate",
+			requestlog.NewHandler(a.InitiatePWResetUser, l),
+		)
+
+		r.Method(
+			"POST",
+			"/password/reset/verify",
+			requestlog.NewHandler(a.VerifyPWResetUser, l),
+		)
+
+		r.Method(
+			"POST",
+			"/password/reset/finalize",
+			requestlog.NewHandler(a.FinalizPWResetUser, l),
+		)
+
 		// /api/integrations routes
 		r.Method(
 			"GET",