diff --git a/application/applicationservice/passwordresetservice.go b/application/applicationservice/passwordresetservice.go new file mode 100644 index 0000000..dc3a8de --- /dev/null +++ b/application/applicationservice/passwordresetservice.go @@ -0,0 +1,62 @@ +package applicationservice + +import ( + "bitbucket.org/nemt/nemt-portal-api/application/entitymapping" + "bitbucket.org/nemt/nemt-portal-api/application/viewmodel" + "bitbucket.org/nemt/nemt-portal-api/domain/service" +) + +// zipcodeService holds methods to participating zipcode application service +type passwordResetService struct { + svc *service.Service + mapEntity *entitymapping.Mapper +} + +// newZipcodeService returns a zipcodeService instance +func newPasswordResetService(svc *service.Service, mapper *entitymapping.Mapper) *passwordResetService { + return &passwordResetService{ + svc: svc, + mapEntity: mapper, + } +} + +func (s *passwordResetService) GetAll() ([]viewmodel.PasswordReset, error) { + result, err := s.svc.PasswordReset.GetAll() + if err != nil { + return nil, err + } + return s.mapEntity.PasswordReset.ToPasswordResetModelSlice(result), nil +} + +func (s *passwordResetService) CreatePasswordResetEntry(passwordResetEntry viewmodel.PasswordReset) (viewmodel.PasswordReset, error) { + passwordResetEntity := s.mapEntity.PasswordReset.ToPasswordResetEntity(passwordResetEntry) + result, err := s.svc.PasswordReset.CreatePasswordResetEntry(passwordResetEntity) + if err != nil { + return viewmodel.PasswordReset{}, err + } + return s.mapEntity.PasswordReset.ToPasswordResetModel(result), nil +} + +func (s *passwordResetService) GetByID(ID int64) (viewmodel.PasswordReset, error) { + result, err := s.svc.PasswordReset.GetByID(ID) + if err != nil { + return viewmodel.PasswordReset{}, err + } + return s.mapEntity.PasswordReset.ToPasswordResetModel(result), nil +} + +func (s *passwordResetService) GetByToken(token string) (viewmodel.PasswordReset, error) { + result, err := s.svc.PasswordReset.GetByToken(token) + if err != nil { + return viewmodel.PasswordReset{}, err + } + return s.mapEntity.PasswordReset.ToPasswordResetModel(result), nil +} + +func (s *passwordResetService) SetTokenOpened(token string) error { + return s.svc.PasswordReset.SetTokenOpened(token) +} + +func (s *passwordResetService) SetTokenUsed(token string) error { + return s.svc.PasswordReset.SetTokenUsed(token) +} diff --git a/application/entitymapping/passwordreset.go b/application/entitymapping/passwordreset.go new file mode 100644 index 0000000..5e5878e --- /dev/null +++ b/application/entitymapping/passwordreset.go @@ -0,0 +1,59 @@ +package entitymapping + +import ( + "bitbucket.org/nemt/nemt-portal-api/application/viewmodel" + "bitbucket.org/nemt/nemt-portal-api/domain/entity" +) + +// zipcodeMapping has method to map zipcode entities to view models +type passwordResetMapping struct { + mapper *Mapper +} + +// ToUserEntitySlice maps a User entity slice to User view model slice +func (mapping *passwordResetMapping) ToPasswordResetEntitySlice(list []viewmodel.PasswordReset) (retVal []entity.PasswordReset) { + retVal = make([]entity.PasswordReset, 0) + + for _, item := range list { + retVal = append(retVal, mapping.ToPasswordResetEntity(item)) + } + + return retVal +} + +func (mapping *passwordResetMapping) ToPasswordResetEntity(model viewmodel.PasswordReset) entity.PasswordReset { + return entity.PasswordReset{ + ID: model.ID, + UUID: model.UUID, + User: mapping.mapper.User.ToUserEntity(model.User), + Token: model.Token, + Created: model.Created, + Expires: model.Expires, + Used: model.Used, + Opened: model.Opened, + } +} + +// ToUserEntitySlice maps a User entity slice to User view model slice +func (mapping *passwordResetMapping) ToPasswordResetModelSlice(list []entity.PasswordReset) (retVal []viewmodel.PasswordReset) { + retVal = make([]viewmodel.PasswordReset, 0) + + for _, item := range list { + retVal = append(retVal, mapping.ToPasswordResetModel(item)) + } + + return retVal +} + +func (mapping *passwordResetMapping) ToPasswordResetModel(model entity.PasswordReset) viewmodel.PasswordReset { + return viewmodel.PasswordReset{ + ID: model.ID, + UUID: model.UUID, + User: mapping.mapper.User.ToUserModel(model.User), + Token: model.Token, + Created: model.Created, + Expires: model.Expires, + Used: model.Used, + Opened: model.Opened, + } +} diff --git a/application/viewmodel/passwordreset.go b/application/viewmodel/passwordreset.go new file mode 100644 index 0000000..c2a2232 --- /dev/null +++ b/application/viewmodel/passwordreset.go @@ -0,0 +1,14 @@ +package viewmodel + +import "time" + +type PasswordReset struct { + ID int64 `json:"-"` + UUID string `json:"uuid"` + User User `json:"user"` + Token string `json:"token"` + Created time.Time `json:"create_date"` + Expires time.Time `json:"expire_date"` + Used bool `json:"used"` + Opened bool `json:"opened"` +} diff --git a/data/datamysql/passwordreset.go b/data/datamysql/passwordreset.go new file mode 100644 index 0000000..af31dec --- /dev/null +++ b/data/datamysql/passwordreset.go @@ -0,0 +1,144 @@ +package datamysql + +import ( + "database/sql" + "fmt" + + "bitbucket.org/nemt/nemt-portal-api/domain/entity" + "bitbucket.org/nemt/nemt-portal-api/infra/errors" + uuid "github.com/satori/go.uuid" +) + +// rideRepo maps methods to database +type passwordResetRepo struct { + conn executor +} + +func newPasswordResetRepo(conn executor) *passwordResetRepo { + return &passwordResetRepo{ + conn: conn, + } +} + +func (c *passwordResetRepo) getQuery() string { + const ( + query = `SELECT + a.password_reset_id, + a.password_reset_uuid, + a.user_id, + b.user_uuid, + a.token, + a.create_date, + a.expire_date, + (IFNULL(a.used, b'0') = b'1') used, + (IFNULL(a.opened, b'0') = b'1') opened + FROM + tab_password_reset a + INNER JOIN tab_user b + ON a.user_id = b.user_id` + ) + + return query +} + +// parseSet parses a result set result to an entity array +func (c *passwordResetRepo) parseSet(rows *sql.Rows, err error) ([]entity.PasswordReset, error) { + if err != nil { + return nil, errors.Wrap(err) + } + result := make([]entity.PasswordReset, 0) + for rows.Next() { + entity, err := c.parseEntity(rows) + if err != nil { + return nil, errors.Wrap(err) + } + result = append(result, entity) + } + return result, nil +} + +// parseEntity parses a result to an entity +func (c *passwordResetRepo) parseEntity(row scanner) (retVal entity.PasswordReset, err error) { + err = row.Scan( + &retVal.ID, &retVal.UUID, &retVal.User.ID, &retVal.User.UUID, &retVal.Token, &retVal.Created, &retVal.Expires, &retVal.Used, &retVal.Opened) + + return retVal, errors.Wrap(err) +} + +func (c *passwordResetRepo) GetAll() ([]entity.PasswordReset, error) { + return c.parseSet(c.conn.Query(c.getQuery())) +} + +func (c *passwordResetRepo) CreatePasswordResetEntry(passwordResetEntry entity.PasswordReset) (entity.PasswordReset, error) { + const ( + insertQuery = `INSERT INTO tab_password_reset(password_reset_uuid, user_id, token, expire_date, used, opened) VALUES(?, ?, ?, ?, 0, 0);` + ) + + retVal := passwordResetEntry + guid, _ := uuid.NewV4() + + userRepo := newUserRepo(c.conn) + + fullUser, err := userRepo.GetByEmail(passwordResetEntry.User.Email) + if err != nil { + return retVal, err + } + + if fullUser.Email != passwordResetEntry.User.Email { + return retVal, fmt.Errorf("User not found") + } + + results, err := c.conn.Exec(insertQuery, guid, fullUser.ID, passwordResetEntry.Token, passwordResetEntry.Expires) + if err != nil { + return retVal, err + } + + retVal.ID, err = results.LastInsertId() + if err != nil { + return retVal, err + } + + return c.GetByID(retVal.ID) +} + +func (c *passwordResetRepo) GetByID(ID int64) (entity.PasswordReset, error) { + return c.parseEntity(c.conn.QueryRow(c.getQuery()+" WHERE a.password_reset_id = ?; ", ID)) +} + +func (c *passwordResetRepo) GetByToken(token string) (entity.PasswordReset, error) { + return c.parseEntity(c.conn.QueryRow(c.getQuery()+" WHERE a.token = ? AND a.used = 0; ", token)) +} + +func (c *passwordResetRepo) SetTokenOpened(token string) error { + const ( + query = `UPDATE tab_password_reset a + SET a.opened = 1 + WHERE a.token = ? AND a.used = 0 AND a.expire_date > CURRENT_TIMESTAMP` + ) + + result, err := c.conn.Exec(query, token) + if err != nil { + return err + } + + if updateCount, err := result.RowsAffected(); err != nil || updateCount == 0 { + return fmt.Errorf("Invalid token") + } + + return nil +} + +func (c *passwordResetRepo) SetTokenUsed(token string) error { + const ( + query = `UPDATE tab_password_reset a + SET a.opened = 1, + a.used = 1 + WHERE a.token = ? AND a.used = 0` + ) + + if _, err := c.conn.Exec(query, token); err != nil { + return err + } + + return nil +} diff --git a/domain/entity/passwordreset.go b/domain/entity/passwordreset.go new file mode 100644 index 0000000..633d1df --- /dev/null +++ b/domain/entity/passwordreset.go @@ -0,0 +1,14 @@ +package entity + +import "time" + +type PasswordReset struct { + ID int64 `db:"password_reset_id" json:"-"` + UUID string `db:"password_reset_uuid" json:"uuid"` + User User `db:"-" json:"user"` + Token string `db:"token" json:"token"` + Created time.Time `db:"create_date" json:"create_date"` + Expires time.Time `db:"expire_date" json:"expire_date"` + Used bool `db:"used" json:"used"` + Opened bool `db:"opend" json:"opened"` +} diff --git a/domain/service/passwordreset.go b/domain/service/passwordreset.go new file mode 100644 index 0000000..2e97aae --- /dev/null +++ b/domain/service/passwordreset.go @@ -0,0 +1,41 @@ +package service + +import ( + "bitbucket.org/nemt/nemt-portal-api/domain/entity" +) + +// userService is the domain service for user operations +type passwordResetService struct { + svc *Service +} + +// newUserService returns an instance of userService +func newPasswordResetService(svc *Service) *passwordResetService { + return &passwordResetService{ + svc: svc, + } +} + +func (s *passwordResetService) GetAll() ([]entity.PasswordReset, error) { + return s.svc.db.PasswordReset().GetAll() +} + +func (s *passwordResetService) GetByID(ID int64) (entity.PasswordReset, error) { + return s.svc.db.PasswordReset().GetByID(ID) +} + +func (s *passwordResetService) GetByToken(token string) (entity.PasswordReset, error) { + return s.svc.db.PasswordReset().GetByToken(token) +} + +func (s *passwordResetService) CreatePasswordResetEntry(passwordResetEntry entity.PasswordReset) (entity.PasswordReset, error) { + return s.svc.db.PasswordReset().CreatePasswordResetEntry(passwordResetEntry) +} + +func (s *passwordResetService) SetTokenOpened(token string) error { + return s.svc.db.PasswordReset().SetTokenOpened(token) +} + +func (s *passwordResetService) SetTokenUsed(token string) error { + return s.svc.db.PasswordReset().SetTokenUsed(token) +} diff --git a/server/router/passwordresetroute/controller.go b/server/router/passwordresetroute/controller.go new file mode 100644 index 0000000..70ea908 --- /dev/null +++ b/server/router/passwordresetroute/controller.go @@ -0,0 +1,168 @@ +package passwordresetroute + +import ( + "crypto/sha256" + "fmt" + "math/rand" + "strings" + "sync" + "time" + + "bitbucket.org/nemt/nemt-portal-api/application/applicationservice" + "bitbucket.org/nemt/nemt-portal-api/application/viewmodel" + "bitbucket.org/nemt/nemt-portal-api/infra/config" + "bitbucket.org/nemt/nemt-portal-api/server/router/routeutils" + "github.com/labstack/echo" +) + +const ( + tokenExpirationTime = 90 // in minutes + randomStringLength = 15 + baseURL = "http://localhost:5000" + passwordResetEmailSubject = "Reset Your Password" + passwordResetEmailMainBody = "To reset your password click here or copy the following link and paste it into your browser: \n\n " + baseURL + "/#/reset-password/" + passwordResetEmailFooter = "\nThis link expires in 90 minutes" + passwordResetCompleteEmailSubject = "Your Password Has been Reset" + passwordResetCompleteEmailBody = "Your password has been reset. To login click here or copy the following link and paste it into your browser: \n\n" + baseURL + "/#/login" +) + +var ( + instance *controller + once sync.Once +) + +type controller struct { + svc *applicationservice.Service + cfg *config.Config +} + +func controllerInstance(svc *applicationservice.Service, cfg *config.Config) *controller { + once.Do(func() { + instance = &controller{ + svc: svc, + cfg: cfg, + } + + rand.Seed(time.Now().UTC().UnixNano()) + }) + return instance +} + +func (c *controller) handleResetRequest(ctx echo.Context) error { + userEmail, err := routeutils.GetAndValidateStringParam(ctx, "email", "mandatory field") + if err != nil { + return routeutils.HandleAPIError(ctx, err) + } + + //find if user with email exists + user, err := c.svc.Users.GetByEmail(userEmail) + if err != nil { + return routeutils.HandleAPIError(ctx, err) + } + + if user.Email == nil || (*user.Email != userEmail) { + return routeutils.ResponseAPIOK(ctx, nil) //more secure, don't inform user (attacker) that email doesn't exists + } + + //create and store reset token + + timeNow := time.Now() + expirationTime := timeNow.Add(time.Minute * tokenExpirationTime) + + randomArray := make([]byte, randomStringLength) + rand.Read(randomArray) + token := fmt.Sprintf("%x", sha256.Sum256(randomArray)) + + passwordResetEntry := viewmodel.PasswordReset{ + User: user, + Token: token, + Expires: expirationTime, + Opened: false, + Used: false, + } + + _, err = c.svc.PasswordReset.CreatePasswordResetEntry(passwordResetEntry) + if err != nil { + return routeutils.HandleAPIError(ctx, err) + } + + //Send email with reset link + notification := viewmodel.Notification{ + Type: applicationservice.NotificationTypeEmail, + From: c.cfg.Email.Sender, + To: *user.Email, + Subject: passwordResetEmailSubject, + Message: passwordResetEmailMainBody + token + passwordResetEmailFooter, + } + + notification, err = c.svc.Notification.SendNotificationWithoutWritingToDatabase(notification) + if err != nil { + return routeutils.HandleAPIError(ctx, err) + } + + return routeutils.ResponseAPIOK(ctx, nil) +} + +func (c *controller) handleResetComplete(ctx echo.Context) error { + userToken, err := routeutils.GetAndValidateStringParam(ctx, "token", "mandatory field") + if err != nil { + return routeutils.HandleAPIError(ctx, err) + } + + var user viewmodel.User + if err = ctx.Bind(&user); err != nil { + return routeutils.HandleAPIError(ctx, err) + } + + if len(strings.TrimSpace(user.Pass)) < 1 { + routeutils.ResponseAPIPasswordResetFailed(ctx, "No password") + } + + passwordResetEntry, err := c.svc.PasswordReset.GetByToken(userToken) + if err != nil || len(passwordResetEntry.Token) < 1 || passwordResetEntry.Expires.Before(time.Now()) || passwordResetEntry.Used == true { + routeutils.ResponseAPIPasswordResetFailed(ctx, "Token error") + } + + fullUserData, err := c.svc.Users.GetByUUID(passwordResetEntry.User.ID, "") + if err != nil { + routeutils.ResponseAPIPasswordResetFailed(ctx, "User problem") + } + + fmt.Println(fullUserData) + + //write new password in database + //TODO + + if err := c.svc.PasswordReset.SetTokenUsed(userToken); err != nil { + routeutils.ResponseAPIPasswordResetFailed(ctx, "Reset failed") + } + + //Send email with reset link + notification := viewmodel.Notification{ + Type: applicationservice.NotificationTypeEmail, + From: c.cfg.Email.Sender, + To: *user.Email, + Subject: passwordResetCompleteEmailSubject, + Message: passwordResetCompleteEmailBody, + } + + notification, err = c.svc.Notification.SendNotificationWithoutWritingToDatabase(notification) + if err != nil { + return routeutils.HandleAPIError(ctx, err) + } + + return routeutils.ResponseAPIOK(ctx, nil) +} + +func (c *controller) handleTokenOpen(ctx echo.Context) error { + token, err := routeutils.GetAndValidateStringParam(ctx, "token", "mandatory field") + if err != nil { + return routeutils.HandleAPIError(ctx, err) + } + + if err := c.svc.PasswordReset.SetTokenOpened(token); err != nil { + return routeutils.HandleAPIError(ctx, err) + } + + return routeutils.ResponseAPIOK(ctx, nil) +} diff --git a/server/router/passwordresetroute/router.go b/server/router/passwordresetroute/router.go new file mode 100644 index 0000000..115b872 --- /dev/null +++ b/server/router/passwordresetroute/router.go @@ -0,0 +1,21 @@ +package passwordresetroute + +import ( + "bitbucket.org/nemt/nemt-portal-api/application/applicationservice" + "bitbucket.org/nemt/nemt-portal-api/infra/config" + "github.com/labstack/echo" +) + +const ( + resetRequest = "/request/:email" + resetComplete = "/complete/:token" + tokenOpen = "/open/:token" +) + +func Register(r *echo.Group, cfg *config.Config, svc *applicationservice.Service) { + ctrl := controllerInstance(svc, cfg) + + r.POST(resetRequest, ctrl.handleResetRequest) + r.POST(resetComplete, ctrl.handleResetComplete) + r.POST(tokenOpen, ctrl.handleTokenOpen) +} diff --git a/svijetlastrana b/svijetlastrana new file mode 100755 index 0000000..8d2981a Binary files /dev/null and b/svijetlastrana differ