Implemented password reset on satellite console web. (#1665)

This commit is contained in:
Bogdan Artemenko 2019-04-10 22:16:10 +03:00 committed by GitHub
parent db2f4615fd
commit 6a50b187eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 320 additions and 54 deletions

View File

@ -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 {

View File

@ -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
},
},
},
})
}

View File

@ -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)
})
})
}

View File

@ -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
}

View File

@ -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{

View File

@ -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)

View File

@ -84,6 +84,33 @@ export async function changePasswordRequest(password: string, newPassword: strin
return result;
}
export async function forgotPasswordRequest(email: string): Promise<RequestResponse<null>> {
let result: RequestResponse<null> = {
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<RequestResponse<null>> {
let result: RequestResponse<null> = {

View File

@ -6,9 +6,9 @@
<div class="team-header">
<HeaderArea/>
</div>
<div id="scrollable_team_container" v-if="projectMembers.length > 0" v-on:scroll="handleScroll" class="team-container">
<div id="scrollable_team_container" v-if="projectMembers.length > 0 || projectMembersCount > 0" v-on:scroll="handleScroll" class="team-container">
<div class="team-container__content">
<div v-for="(member, index) in projectMembers" v-on:click="onMemberClick(member)" v-bind:key="index">
<div v-for="member in projectMembers" v-on:click="onMemberClick(member)" v-bind:key="member.id">
<TeamMemberItem
:projectMember = "member"
v-bind:class = "[member.isSelected ? 'selected' : '']"
@ -20,7 +20,7 @@
<Footer/>
</div>
</div>
<div class="empty-search-result-area" v-if="projectMembers.length === 0">
<div class="empty-search-result-area" v-if="(projectMembers.length === 0 && projectMembersCount === 0)">
<h1 class="empty-search-result-area__text">No results found</h1>
<div class="empty-search-result-area__image" v-html="emptyImage"></div>
</div>
@ -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;
},

View File

@ -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),
},
};

View File

@ -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<any> {
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 {

View File

@ -27,7 +27,7 @@
height="46px"
isWhite>
</HeaderlessInput>
<div class="forgot-password-area__submit-container">
<div class="forgot-password-area__submit-container" v-on:click.prevent="onSendConfigurations">
<div class="forgot-password-area__submit-container__send-button" >
<p>Send Configurations</p>
</div>

View File

@ -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: {

View File

@ -33,7 +33,7 @@
isPassword>
</HeaderlessInput>
<div class="login-area__submit-area">
<router-link to="" class="login-area__navigation-area__nav-link" exact>
<router-link v-bind:to="forgotPasswordRouterPath" class="login-area__navigation-area__nav-link" exact>
<h3><strong>Forgot password?</strong></h3>
</router-link>
<div class="login-area__submit-area__login-button" v-on:click.prevent="onLogin">

View File

@ -512,7 +512,7 @@ To reset your password, click the following link and follow the instructions. &#
<div style="Margin-left: 20px;Margin-right: 20px;Margin-top: 12px;Margin-bottom: 12px;">
<div class="btn btn--flat btn--large" style="text-align:left;">
<![if !mso]><a style="border-radius: 4px;display: inline-block;font-size: 14px;font-weight: bold;line-height: 24px;padding: 12px 24px;text-align: center;text-decoration: none !important;transition: opacity 0.1s ease-in;color: #ffffff !important;background-color: #2683ff;font-family: Montserrat, DejaVu Sans, Verdana, sans-serif;" href="{{ .ResetLink }}">Reset Password</a><![endif]>
<a style="border-radius: 4px;display: inline-block;font-size: 14px;font-weight: bold;line-height: 24px;padding: 12px 24px;text-align: center;text-decoration: none !important;transition: opacity 0.1s ease-in;color: #ffffff !important;background-color: #2683ff;font-family: Montserrat, DejaVu Sans, Verdana, sans-serif;" href="{{ .ResetLink }}">Reset Password</a>
<!--[if mso]><p style="line-height:0;margin:0;">&nbsp;</p><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" href="{{ .ResetLink }}" style="width:191px" arcsize="9%" fillcolor="#2683FF" stroke="f"><v:textbox style="mso-fit-shape-to-text:t" inset="0px,11px,0px,11px"><center style="font-size:14px;line-height:24px;color:#FFFFFF;font-family:Montserrat,DejaVu Sans,Verdana,sans-serif;font-weight:bold;mso-line-height-rule:exactly;mso-text-raise:4px">Reset Password</center></v:textbox></v:roundrect><![endif]--></div>
</div>

View File

@ -113,6 +113,8 @@ body {
justify-content: space-between;
width: 100%;
margin-top: 20px;
border: none;
padding: 0;
}
.reset-password-area__submit-container__send-button {
display: flex;

View File

@ -4,43 +4,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tardigrade Satellite - Mars</title>
<meta charset="UTF-8">
<title>Tardigrade Satellite - Mars</title>
<link href="/static/static/fonts/font_regular.ttf" rel="stylesheet">
<link href="/static/static/fonts/font_bold.ttf" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="resetPassword.css">
<link href="/static/static/fonts/font_regular.ttf" rel="stylesheet">
<link href="/static/static/fonts/font_bold.ttf" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="/static/static/resetPassword/resetPassword.css">
</head>
<body>
<div class="reset-password-container">
<img class="image" src="../images/AuthImage.svg" alt="" >
<div class="reset-password-container__wrapper">
<div class="reset-password-container__header">
<img class="reset-password-container__logo" src="../images/Logo.svg" alt="logo">
<div class="reset-password-container__login-button" >
<p>Back to Login</p>
</div>
</div>
<div class="reset-password-area-wrapper">
<div class="reset-password-area">
<div class="reset-password-area__title-container">
<h1>Please enter your new password.</h1>
</div>
<input
type="password"
placeholder="New Password">
<input
type="password"
placeholder="Confirm Password">
<div class="reset-password-area__submit-container">
<div class="reset-password-area__submit-container__send-button" >
<p>Set New Password</p>
</div>
</div>
</div>
</div>
</div>
</div>
<form method="POST">
<div class="reset-password-container">
<img class="image" src="/static/static/images/AuthImage.svg" alt="">
<div class="reset-password-container__wrapper">
<div class="reset-password-container__header">
<img class="reset-password-container__logo" src="/static/static/images/Logo.svg" alt="logo">
<a href="/login">
<div class="reset-password-container__login-button">
<p>Back to Login</p>
</div>
</a>
</div>
<div class="reset-password-area-wrapper">
<div class="reset-password-area">
<div class="reset-password-area__title-container">
<h1>Please enter your new password.</h1>
</div>
<input
type="password"
name="password"
placeholder="New Password">
<input
type="password"
name="passwordRepeat"
placeholder="Confirm Password">
<button type="submit" class="reset-password-area__submit-container">
<div class="reset-password-area__submit-container__send-button">
<p>Set New Password</p>
</div>
</button>
</div>
</div>
</div>
</div>
</form>
</body>
</html>