From 6a50b187eb1abfb39c92142b9e23c072cc1bf699 Mon Sep 17 00:00:00 2001 From: Bogdan Artemenko Date: Wed, 10 Apr 2019 22:16:10 +0300 Subject: [PATCH] Implemented password reset on satellite console web. (#1665) --- .../console/consoleweb/consoleql/mail.go | 4 +- .../console/consoleweb/consoleql/query.go | 53 ++++++++++++- .../consoleweb/consoleql/query_test.go | 46 ++++++++++++ .../consoleweb/consoleql/typecreator.go | 2 +- satellite/console/consoleweb/server.go | 46 +++++++++++- satellite/console/service.go | 75 ++++++++++++++++--- web/satellite/src/api/users.ts | 27 +++++++ .../src/components/team/TeamArea.vue | 9 ++- .../src/store/modules/projectMembers.ts | 8 +- .../views/forgotPassword/ForgotPassword.vue | 20 +++++ .../views/forgotPassword/forgotPassword.html | 2 +- web/satellite/src/views/login/Login.vue | 1 + web/satellite/src/views/login/login.html | 2 +- web/satellite/static/emails/Forgot.html | 2 +- .../static/resetPassword/resetPassword.css | 2 + .../static/resetPassword/resetPassword.html | 75 ++++++++++--------- 16 files changed, 320 insertions(+), 54 deletions(-) diff --git a/satellite/console/consoleweb/consoleql/mail.go b/satellite/console/consoleweb/consoleql/mail.go index 4f6c98509..49f9e215e 100644 --- a/satellite/console/consoleweb/consoleql/mail.go +++ b/satellite/console/consoleweb/consoleql/mail.go @@ -6,6 +6,8 @@ package consoleql const ( // ActivationPath is key for path which handles account activation ActivationPath = "activationPath" + // PasswordRecoveryPath is key for path which handles password recovery + PasswordRecoveryPath = "passwordRecoveryPath" // SignInPath is key for sign in server route SignInPath = "signInPath" ) @@ -34,7 +36,7 @@ type ForgotPasswordEmail struct { func (*ForgotPasswordEmail) Template() string { return "Forgot" } // Subject gets email subject -func (*ForgotPasswordEmail) Subject() string { return "" } +func (*ForgotPasswordEmail) Subject() string { return "Password recovery request" } // ProjectInvitationEmail is mailservice template for project invitation email type ProjectInvitationEmail struct { diff --git a/satellite/console/consoleweb/consoleql/query.go b/satellite/console/consoleweb/consoleql/query.go index b6c4b055e..a41a741f9 100644 --- a/satellite/console/consoleweb/consoleql/query.go +++ b/satellite/console/consoleweb/consoleql/query.go @@ -4,10 +4,15 @@ package consoleql import ( + "errors" + "fmt" + "github.com/graphql-go/graphql" "github.com/skyrings/skyring-common/tools/uuid" + "storj.io/storj/internal/post" "storj.io/storj/satellite/console" + "storj.io/storj/satellite/mailservice" ) const ( @@ -21,10 +26,12 @@ const ( MyProjectsQuery = "myProjects" // TokenQuery is a query name for token TokenQuery = "token" + // ForgotPasswordQuery is a query name for password recovery request + ForgotPasswordQuery = "forgotPassword" ) // rootQuery creates query for graphql populated by AccountsClient -func rootQuery(service *console.Service, types *TypeCreator) *graphql.Object { +func rootQuery(service *console.Service, mailService *mailservice.Service, types *TypeCreator) *graphql.Object { return graphql.NewObject(graphql.ObjectConfig{ Name: Query, Fields: graphql.Fields{ @@ -90,6 +97,50 @@ func rootQuery(service *console.Service, types *TypeCreator) *graphql.Object { return tokenWrapper{Token: token}, nil }, }, + ForgotPasswordQuery: &graphql.Field{ + Type: graphql.Boolean, + Args: graphql.FieldConfigArgument{ + FieldEmail: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + email, _ := p.Args[FieldEmail].(string) + + user, err := service.GetUserByEmail(p.Context, email) + if err != nil { + return false, fmt.Errorf("%s is not found", email) + } + + recoveryToken, err := service.GeneratePasswordRecoveryToken(p.Context, user.ID, user.Email) + if err != nil { + return false, errors.New("failed to generate password recovery token") + } + + rootObject := p.Info.RootValue.(map[string]interface{}) + origin := rootObject["origin"].(string) + link := origin + rootObject[PasswordRecoveryPath].(string) + recoveryToken + userName := user.ShortName + if user.ShortName == "" { + userName = user.FullName + } + + // TODO: think of a better solution + go func() { + _ = mailService.SendRendered( + p.Context, + []post.Address{{Address: user.Email, Name: userName}}, + &ForgotPasswordEmail{ + Origin: origin, + ResetLink: link, + UserName: userName, + }, + ) + }() + + return true, nil + }, + }, }, }) } diff --git a/satellite/console/consoleweb/consoleql/query_test.go b/satellite/console/consoleweb/consoleql/query_test.go index b84c8e32b..9090b6414 100644 --- a/satellite/console/consoleweb/consoleql/query_test.go +++ b/satellite/console/consoleweb/consoleql/query_test.go @@ -466,5 +466,51 @@ func TestGraphqlQuery(t *testing.T) { assert.NoError(t, err) assert.True(t, rootUser.CreatedAt.Equal(createdAt)) }) + + t.Run("PasswordReset query", func(t *testing.T) { + regToken, err := service.CreateRegToken(ctx, 2) + if err != nil { + t.Fatal(err) + } + user, err := service.CreateUser(authCtx, console.CreateUser{ + UserInfo: console.UserInfo{ + FullName: "Example User", + ShortName: "Example", + Email: "user@example.com", + }, + Password: "123a123", + }, regToken.Secret) + + if err != nil { + t.Fatal(err) + } + + t.Run("Activation", func(t *testing.T) { + activationToken, err := service.GenerateActivationToken( + ctx, + user.ID, + "user@example.com", + ) + if err != nil { + t.Fatal(err) + } + err = service.ActivateAccount(ctx, activationToken) + if err != nil { + t.Fatal(err) + } + user.Email = "user@example.com" + + }) + + rootObject[consoleql.PasswordRecoveryPath] = "?activationToken=" + query := fmt.Sprintf("query {forgotPassword(email: \"%s\")}", user.Email) + + result := testQuery(t, query) + assert.NotNil(t, result) + data := result.(map[string]interface{}) + + ok := data[consoleql.ForgotPasswordQuery].(bool) + assert.True(t, ok) + }) }) } diff --git a/satellite/console/consoleweb/consoleql/typecreator.go b/satellite/console/consoleweb/consoleql/typecreator.go index d4200b30b..1ef1c7ef4 100644 --- a/satellite/console/consoleweb/consoleql/typecreator.go +++ b/satellite/console/consoleweb/consoleql/typecreator.go @@ -79,7 +79,7 @@ func (c *TypeCreator) Create(log *zap.Logger, service *console.Service, mailServ } // root objects - c.query = rootQuery(service, c) + c.query = rootQuery(service, mailService, c) if err := c.query.Error(); err != nil { return err } diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index efd22a45a..18ec71f03 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -93,6 +93,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, mail if server.config.StaticDir != "" { mux.Handle("/activation/", http.HandlerFunc(server.accountActivationHandler)) + mux.Handle("/password-recovery/", http.HandlerFunc(server.passwordRecoveryHandler)) mux.Handle("/registrationToken/", http.HandlerFunc(server.createRegistrationTokenHandler)) mux.Handle("/usage-report/", http.HandlerFunc(server.bucketUsageReportHandler)) mux.Handle("/static/", http.StripPrefix("/static", fs)) @@ -224,13 +225,55 @@ func (s *Server) accountActivationHandler(w http.ResponseWriter, req *http.Reque zap.String("token", activationToken), zap.Error(err)) - http.ServeFile(w, req, filepath.Join(s.config.StaticDir, "static", "errors", "404.html")) + s.serveError(w, req) return } http.ServeFile(w, req, filepath.Join(s.config.StaticDir, "static", "activation", "success.html")) } +func (s *Server) passwordRecoveryHandler(w http.ResponseWriter, req *http.Request) { + recoveryToken := req.URL.Query().Get("token") + if len(recoveryToken) == 0 { + s.serveError(w, req) + return + } + + switch req.Method { + case "POST": + err := req.ParseForm() + if err != nil { + s.serveError(w, req) + } + + password := req.FormValue("password") + passwordRepeat := req.FormValue("passwordRepeat") + if strings.Compare(password, passwordRepeat) != 0 { + s.serveError(w, req) + return + } + + err = s.service.ResetPassword(context.Background(), recoveryToken, password) + if err != nil { + s.serveError(w, req) + } + default: + t, err := template.ParseFiles(filepath.Join(s.config.StaticDir, "static", "resetPassword", "resetPassword.html")) + if err != nil { + s.serveError(w, req) + } + + err = t.Execute(w, nil) + if err != nil { + s.serveError(w, req) + } + } +} + +func (s *Server) serveError(w http.ResponseWriter, req *http.Request) { + http.ServeFile(w, req, filepath.Join(s.config.StaticDir, "static", "errors", "404.html")) +} + // grapqlHandler is graphql endpoint http handler function func (s *Server) grapqlHandler(w http.ResponseWriter, req *http.Request) { w.Header().Set(contentType, applicationJSON) @@ -254,6 +297,7 @@ func (s *Server) grapqlHandler(w http.ResponseWriter, req *http.Request) { rootObject["origin"] = s.config.ExternalAddress rootObject[consoleql.ActivationPath] = "activation/?token=" + rootObject[consoleql.PasswordRecoveryPath] = "password-recovery/?token=" rootObject[consoleql.SignInPath] = "login" result := graphql.Do(graphql.Params{ diff --git a/satellite/console/service.go b/satellite/console/service.go index 6e4e011fd..396a234bc 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -35,15 +35,16 @@ const ( // Error messages const ( - internalErrMsg = "It looks like we had a problem on our end. Please try again" - unauthorizedErrMsg = "You are not authorized to perform this action" - vanguardRegTokenErrMsg = "We are unable to create your account. This is an invite-only alpha, please join our waitlist to receive an invitation" - emailUsedErrMsg = "This email is already in use, try another" - activationTokenIsExpiredErrMsg = "Your account activation link has expired, please sign up again" - credentialsErrMsg = "Your email or password was incorrect, please try again" - oldPassIncorrectErrMsg = "Old password is incorrect, please try again" - passwordIncorrectErrMsg = "Your password needs at least %d characters long" - teamMemberDoesNotExistErrMsg = `There is no account on this Satellite for the user(s) you have entered. + internalErrMsg = "It looks like we had a problem on our end. Please try again" + unauthorizedErrMsg = "You are not authorized to perform this action" + vanguardRegTokenErrMsg = "We are unable to create your account. This is an invite-only alpha, please join our waitlist to receive an invitation" + emailUsedErrMsg = "This email is already in use, try another" + activationTokenIsExpiredErrMsg = "Your account activation link has expired, please sign up again" + passwordRecoveryTokenIsExpiredErrMsg = "Your password recovery link has expired, please request another one" + credentialsErrMsg = "Your email or password was incorrect, please try again" + oldPassIncorrectErrMsg = "Old password is incorrect, please try again" + passwordIncorrectErrMsg = "Your password needs at least %d characters long" + teamMemberDoesNotExistErrMsg = `There is no account on this Satellite for the user(s) you have entered. Please add team members with active accounts` // TODO: remove after vanguard release @@ -149,6 +150,19 @@ func (s *Service) GenerateActivationToken(ctx context.Context, id uuid.UUID, ema return s.createToken(claims) } +// GeneratePasswordRecoveryToken - is a method for generating password recovery token +func (s *Service) GeneratePasswordRecoveryToken(ctx context.Context, id uuid.UUID, email string) (token string, err error) { + defer mon.Task()(&ctx)(&err) + + claims := &consoleauth.Claims{ + ID: id, + Email: email, + Expiration: time.Now().Add(time.Hour), + } + + return s.createToken(claims) +} + // ActivateAccount - is a method for activating user account after registration func (s *Service) ActivateAccount(ctx context.Context, activationToken string) (err error) { defer mon.Task()(&ctx)(&err) @@ -193,6 +207,42 @@ func (s *Service) ActivateAccount(ctx context.Context, activationToken string) ( return nil } +// ResetPassword - is a method for reseting user password +func (s *Service) ResetPassword(ctx context.Context, resetPasswordToken, password string) (err error) { + defer mon.Task()(&ctx)(&err) + + token, err := consoleauth.FromBase64URLString(resetPasswordToken) + if err != nil { + return + } + + claims, err := s.authenticate(token) + if err != nil { + return + } + + user, err := s.store.Users().Get(ctx, claims.ID) + if err != nil { + return + } + + if err := validatePassword(password); err != nil { + return err + } + + if time.Since(claims.Expiration) > 0 { + return errs.New(passwordRecoveryTokenIsExpiredErrMsg) + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), s.passwordCost) + if err != nil { + return err + } + + user.PasswordHash = hash + return s.store.Users().Update(ctx, user) +} + // Token authenticates User by credentials and returns auth token func (s *Service) Token(ctx context.Context, email, password string) (token string, err error) { defer mon.Task()(&ctx)(&err) @@ -238,6 +288,13 @@ func (s *Service) GetUser(ctx context.Context, id uuid.UUID) (u *User, err error return user, nil } +// GetUserByEmail returns User by email +func (s *Service) GetUserByEmail(ctx context.Context, email string) (u *User, err error) { + defer mon.Task()(&ctx)(&err) + + return s.store.Users().GetByEmail(ctx, email) +} + // UpdateAccount updates User func (s *Service) UpdateAccount(ctx context.Context, info UserInfo) (err error) { defer mon.Task()(&ctx)(&err) diff --git a/web/satellite/src/api/users.ts b/web/satellite/src/api/users.ts index 225515277..4c0a15cb9 100644 --- a/web/satellite/src/api/users.ts +++ b/web/satellite/src/api/users.ts @@ -84,6 +84,33 @@ export async function changePasswordRequest(password: string, newPassword: strin return result; } +export async function forgotPasswordRequest(email: string): Promise> { + let result: RequestResponse = { + errorMessage: '', + isSuccess: false, + data: null + }; + + let response: any = await apolloManager.query( + { + query: gql(` + query { + forgotPassword(email: "${email}") + }`), + fetchPolicy: 'no-cache', + errorPolicy: 'all', + }, + ); + + if (response.errors) { + result.errorMessage = response.errors[0].message; + } else { + result.isSuccess = true; + } + + return result; +} + // Performs Create user graqhQL request. export async function createUserRequest(user: User, password: string, secret: string): Promise> { let result: RequestResponse = { diff --git a/web/satellite/src/components/team/TeamArea.vue b/web/satellite/src/components/team/TeamArea.vue index fdfa1fb5e..6b9411539 100644 --- a/web/satellite/src/components/team/TeamArea.vue +++ b/web/satellite/src/components/team/TeamArea.vue @@ -6,9 +6,9 @@
-
+
-
+
-
+

No results found

@@ -71,6 +71,9 @@ import { NOTIFICATION_ACTIONS, PM_ACTIONS } from '@/utils/constants/actionNames' projectMembers: function () { return this.$store.getters.projectMembers; }, + projectMembersCount: function () { + return this.$store.getters.projectMembersCountGetter; + }, selectedProjectMembers: function () { return this.$store.getters.selectedProjectMembers; }, diff --git a/web/satellite/src/store/modules/projectMembers.ts b/web/satellite/src/store/modules/projectMembers.ts index d7229db78..449c1f9ff 100644 --- a/web/satellite/src/store/modules/projectMembers.ts +++ b/web/satellite/src/store/modules/projectMembers.ts @@ -12,6 +12,7 @@ import { ProjectMemberSortByEnum } from '@/utils/constants/ProjectMemberSortEnum export const projectMembersModule = { state: { projectMembers: [], + projectMembersCount: 0, searchParameters: { sortBy: ProjectMemberSortByEnum.NAME, searchQuery: '' @@ -48,7 +49,11 @@ export const projectMembersModule = { }); }, [PROJECT_MEMBER_MUTATIONS.FETCH](state: any, teamMembers: any[]) { - state.projectMembers = state.projectMembers.concat(teamMembers); + teamMembers.forEach(value => { + state.projectMembers.push(value); + + }); + state.projectMembersCount = state.projectMembers.length; }, [PROJECT_MEMBER_MUTATIONS.CLEAR](state: any) { state.projectMembers = []; @@ -124,6 +129,7 @@ export const projectMembersModule = { }, getters: { projectMembers: (state: any) => state.projectMembers, + projectMembersCountGetter: (state: any) => state.projectMembersCount, selectedProjectMembers: (state: any) => state.projectMembers.filter((member: any) => member.isSelected), }, }; diff --git a/web/satellite/src/views/forgotPassword/ForgotPassword.vue b/web/satellite/src/views/forgotPassword/ForgotPassword.vue index dd7cddcf3..747261004 100644 --- a/web/satellite/src/views/forgotPassword/ForgotPassword.vue +++ b/web/satellite/src/views/forgotPassword/ForgotPassword.vue @@ -7,17 +7,37 @@ import { Component, Vue } from 'vue-property-decorator'; import HeaderlessInput from '../../components/common/HeaderlessInput.vue'; import { LOADING_CLASSES } from '@/utils/constants/classConstants'; + import { forgotPasswordRequest } from '@/api/users'; + import { NOTIFICATION_ACTIONS } from '@/utils/constants/actionNames'; @Component( { data: function () { return { loadingClassName: LOADING_CLASSES.LOADING_OVERLAY, + email: '', }; }, components: { HeaderlessInput, }, + methods: { + setEmail: function (value: string): void { + this.$data.email = value; + }, + onSendConfigurations: async function (): Promise { + if (!this.$data.email) { + return; + } + + let passwordRecoveryResponse = await forgotPasswordRequest(this.$data.email); + if (passwordRecoveryResponse.isSuccess) { + this.$store.dispatch(NOTIFICATION_ACTIONS.SUCCESS, 'Please look for instructions at your email'); + } else { + this.$store.dispatch(NOTIFICATION_ACTIONS.ERROR, passwordRecoveryResponse.errorMessage); + } + }, + } }) export default class ForgotPassword extends Vue { diff --git a/web/satellite/src/views/forgotPassword/forgotPassword.html b/web/satellite/src/views/forgotPassword/forgotPassword.html index a1631a221..555d4ba11 100644 --- a/web/satellite/src/views/forgotPassword/forgotPassword.html +++ b/web/satellite/src/views/forgotPassword/forgotPassword.html @@ -27,7 +27,7 @@ height="46px" isWhite> -
+

Send Configurations

diff --git a/web/satellite/src/views/login/Login.vue b/web/satellite/src/views/login/Login.vue index 44bf42abc..6ad350b92 100644 --- a/web/satellite/src/views/login/Login.vue +++ b/web/satellite/src/views/login/Login.vue @@ -22,6 +22,7 @@ import { AppState } from '@/utils/constants/appStateEnum'; password: '', loadingClassName: LOADING_CLASSES.LOADING_OVERLAY, loadingLogoClassName: LOADING_CLASSES.LOADING_LOGO, + forgotPasswordRouterPath: ROUTES.FORGOT_PASSWORD.path, }; }, methods: { diff --git a/web/satellite/src/views/login/login.html b/web/satellite/src/views/login/login.html index 280409798..257c579f2 100644 --- a/web/satellite/src/views/login/login.html +++ b/web/satellite/src/views/login/login.html @@ -33,7 +33,7 @@ isPassword>