From dc050f4d70ebee380d3533d570099f99ebdafa37 Mon Sep 17 00:00:00 2001 From: Mussie Teshome Date: Mon, 26 Jun 2023 14:23:40 +0300 Subject: [PATCH 1/6] forgot_password and reset_password query update --- server/graph/generated/generated.go | 29 +++++++++++++++++++---------- server/graph/schema.graphqls | 5 +++-- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/server/graph/generated/generated.go b/server/graph/generated/generated.go index e849306..a9fd4e8 100644 --- a/server/graph/generated/generated.go +++ b/server/graph/generated/generated.go @@ -2577,15 +2577,16 @@ input UpdateUserInput { } input ForgotPasswordInput { - email: String! + email_or_phone: String! state: String redirect_uri: String } input ResetPasswordInput { - token: String! + token_or_code: String! password: String! confirm_password: String! + phone_number: String } input DeleteUserInput { @@ -15726,18 +15727,18 @@ func (ec *executionContext) unmarshalInputForgotPasswordInput(ctx context.Contex asMap[k] = v } - fieldsInOrder := [...]string{"email", "state", "redirect_uri"} + fieldsInOrder := [...]string{"email_or_phone", "state", "redirect_uri"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { continue } switch k { - case "email": + case "email_or_phone": var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) - it.Email, err = ec.unmarshalNString2string(ctx, v) + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email_or_phone")) + it.EmailOrPhone, err = ec.unmarshalNString2string(ctx, v) if err != nil { return it, err } @@ -16406,18 +16407,18 @@ func (ec *executionContext) unmarshalInputResetPasswordInput(ctx context.Context asMap[k] = v } - fieldsInOrder := [...]string{"token", "password", "confirm_password"} + fieldsInOrder := [...]string{"token_or_code", "password", "confirm_password", "phone_number"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { continue } switch k { - case "token": + case "token_or_code": var err error - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token")) - it.Token, err = ec.unmarshalNString2string(ctx, v) + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token_or_code")) + it.TokenOrCode, err = ec.unmarshalNString2string(ctx, v) if err != nil { return it, err } @@ -16437,6 +16438,14 @@ func (ec *executionContext) unmarshalInputResetPasswordInput(ctx context.Context if err != nil { return it, err } + case "phone_number": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("phone_number")) + it.PhoneNumber, err = ec.unmarshalOString2áš–string(ctx, v) + if err != nil { + return it, err + } } } diff --git a/server/graph/schema.graphqls b/server/graph/schema.graphqls index 4013b12..7ebd0c2 100644 --- a/server/graph/schema.graphqls +++ b/server/graph/schema.graphqls @@ -418,15 +418,16 @@ input UpdateUserInput { } input ForgotPasswordInput { - email: String! + email_or_phone: String! state: String redirect_uri: String } input ResetPasswordInput { - token: String! + token_or_code: String! password: String! confirm_password: String! + phone_number: String } input DeleteUserInput { From 3b7c47bfb35f39cf477db99ac571b1afd6bf2304 Mon Sep 17 00:00:00 2001 From: Mussie Teshome Date: Mon, 26 Jun 2023 14:23:51 +0300 Subject: [PATCH 2/6] generated model --- server/graph/model/models_gen.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/server/graph/model/models_gen.go b/server/graph/model/models_gen.go index 7a1e376..9b81fee 100644 --- a/server/graph/model/models_gen.go +++ b/server/graph/model/models_gen.go @@ -120,7 +120,6 @@ type Env struct { AdminCookieSecure bool `json:"ADMIN_COOKIE_SECURE"` DefaultAuthorizeResponseType *string `json:"DEFAULT_AUTHORIZE_RESPONSE_TYPE"` DefaultAuthorizeResponseMode *string `json:"DEFAULT_AUTHORIZE_RESPONSE_MODE"` - SmsCodeExpiryTime *string `json:"SMS_CODE_EXPIRY_TIME"` } type Error struct { @@ -129,9 +128,9 @@ type Error struct { } type ForgotPasswordInput struct { - Email string `json:"email"` - State *string `json:"state"` - RedirectURI *string `json:"redirect_uri"` + EmailOrPhone string `json:"email_or_phone"` + State *string `json:"state"` + RedirectURI *string `json:"redirect_uri"` } type GenerateJWTKeysInput struct { @@ -257,9 +256,10 @@ type ResendVerifyEmailInput struct { } type ResetPasswordInput struct { - Token string `json:"token"` - Password string `json:"password"` - ConfirmPassword string `json:"confirm_password"` + TokenOrCode string `json:"token_or_code"` + Password string `json:"password"` + ConfirmPassword string `json:"confirm_password"` + PhoneNumber *string `json:"phone_number"` } type Response struct { From ea2a7db8e02554a710e0ec91c6ab1afec1f38ca4 Mon Sep 17 00:00:00 2001 From: Mussie Teshome Date: Mon, 26 Jun 2023 14:25:13 +0300 Subject: [PATCH 3/6] forgot password - with phone --- server/resolvers/forgot_password.go | 158 ++++++++++++++++++---------- 1 file changed, 105 insertions(+), 53 deletions(-) diff --git a/server/resolvers/forgot_password.go b/server/resolvers/forgot_password.go index f497b31..7220f51 100644 --- a/server/resolvers/forgot_password.go +++ b/server/resolvers/forgot_password.go @@ -19,6 +19,7 @@ import ( "github.com/authorizerdev/authorizer/server/token" "github.com/authorizerdev/authorizer/server/utils" "github.com/authorizerdev/authorizer/server/validators" + "github.com/authorizerdev/authorizer/server/smsproviders" ) // ForgotPasswordResolver is a resolver for forgot password mutation @@ -31,79 +32,130 @@ func ForgotPasswordResolver(ctx context.Context, params model.ForgotPasswordInpu return res, err } + disablePhoneVerification, _ := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisablePhoneVerification) isBasicAuthDisabled, err := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisableBasicAuthentication) + if err != nil { log.Debug("Error getting basic auth disabled: ", err) isBasicAuthDisabled = true } + if isBasicAuthDisabled { log.Debug("Basic authentication is disabled") return res, fmt.Errorf(`basic authentication is disabled for this instance`) } - params.Email = strings.ToLower(params.Email) - if !validators.IsValidEmail(params.Email) { - log.Debug("Invalid email address: ", params.Email) - return res, fmt.Errorf("invalid email") + mobile := strings.TrimSpace(params.EmailOrPhone) + + if !validators.IsValidEmail(params.EmailOrPhone) && len(mobile) < 10 { + log.Debug("Invalid email or phone: ", params.EmailOrPhone) + return res, fmt.Errorf("invalid email or phone") } - log := log.WithFields(log.Fields{ - "email": params.Email, - }) - user, err := db.Provider.GetUserByEmail(ctx, params.Email) - if err != nil { - log.Debug("User not found: ", err) - return res, fmt.Errorf(`user with this email not found`) - } + if validators.IsValidEmail(params.EmailOrPhone) { + + params.EmailOrPhone = strings.ToLower(params.EmailOrPhone) - hostname := parsers.GetHost(gc) - _, nonceHash, err := utils.GenerateNonce() - if err != nil { - log.Debug("Failed to generate nonce: ", err) - return res, err - } - - redirectURI := "" - // give higher preference to params redirect uri - if strings.TrimSpace(refs.StringValue(params.RedirectURI)) != "" { - redirectURI = refs.StringValue(params.RedirectURI) - } else { - redirectURI, err = memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyResetPasswordURL) + log := log.WithFields(log.Fields{ + "email": params.EmailOrPhone, + }) + user, err := db.Provider.GetUserByEmail(ctx, params.EmailOrPhone) if err != nil { - log.Debug("ResetPasswordURL not found using default app url: ", err) - redirectURI = hostname + "/app/reset-password" - memorystore.Provider.UpdateEnvVariable(constants.EnvKeyResetPasswordURL, redirectURI) + log.Debug("User not found: ", err) + return res, fmt.Errorf(`user with this email not found`) } + + hostname := parsers.GetHost(gc) + _, nonceHash, err := utils.GenerateNonce() + if err != nil { + log.Debug("Failed to generate nonce: ", err) + return res, err + } + + redirectURI := "" + // give higher preference to params redirect uri + if strings.TrimSpace(refs.StringValue(params.RedirectURI)) != "" { + redirectURI = refs.StringValue(params.RedirectURI) + } else { + redirectURI, err = memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyResetPasswordURL) + if err != nil { + log.Debug("ResetPasswordURL not found using default app url: ", err) + redirectURI = hostname + "/app/reset-password" + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyResetPasswordURL, redirectURI) + } + } + + verificationToken, err := token.CreateVerificationToken(params.EmailOrPhone, constants.VerificationTypeForgotPassword, hostname, nonceHash, redirectURI) + if err != nil { + log.Debug("Failed to create verification token", err) + return res, err + } + _, err = db.Provider.AddVerificationRequest(ctx, models.VerificationRequest{ + Token: verificationToken, + Identifier: constants.VerificationTypeForgotPassword, + ExpiresAt: time.Now().Add(time.Minute * 30).Unix(), + Email: params.EmailOrPhone, + Nonce: nonceHash, + RedirectURI: redirectURI, + }) + if err != nil { + log.Debug("Failed to add verification request", err) + return res, err + } + + // execute it as go routine so that we can reduce the api latency + go email.SendEmail([]string{params.EmailOrPhone}, constants.VerificationTypeForgotPassword, map[string]interface{}{ + "user": user.ToMap(), + "organization": utils.GetOrganization(), + "verification_url": utils.GetForgotPasswordURL(verificationToken, redirectURI), + }) + + res = &model.Response{ + Message: `Please check your inbox! We have sent a password reset link.`, + } + + return res, nil } - verificationToken, err := token.CreateVerificationToken(params.Email, constants.VerificationTypeForgotPassword, hostname, nonceHash, redirectURI) - if err != nil { - log.Debug("Failed to create verification token", err) - return res, err - } - _, err = db.Provider.AddVerificationRequest(ctx, models.VerificationRequest{ - Token: verificationToken, - Identifier: constants.VerificationTypeForgotPassword, - ExpiresAt: time.Now().Add(time.Minute * 30).Unix(), - Email: params.Email, - Nonce: nonceHash, - RedirectURI: redirectURI, - }) - if err != nil { - log.Debug("Failed to add verification request", err) - return res, err - } + if !disablePhoneVerification && len(mobile) > 9 { + + if _, err := db.Provider.GetUserByPhoneNumber(ctx, refs.StringValue(¶ms.EmailOrPhone)); err != nil { + return res, fmt.Errorf("user with given phone number does not exist") + } + + duration, _ := time.ParseDuration("10m") + smsCode := utils.GenerateOTP() + + smsBody := strings.Builder{} + smsBody.WriteString("Your verification code is: ") + smsBody.WriteString(smsCode) - // execute it as go routine so that we can reduce the api latency - go email.SendEmail([]string{params.Email}, constants.VerificationTypeForgotPassword, map[string]interface{}{ - "user": user.ToMap(), - "organization": utils.GetOrganization(), - "verification_url": utils.GetForgotPasswordURL(verificationToken, redirectURI), - }) + go func() { + _, err = db.Provider.UpsertSMSRequest(ctx, &models.SMSVerificationRequest{ + PhoneNumber: params.EmailOrPhone, + Code: smsCode, + CodeExpiresAt: time.Now().Add(duration).Unix(), + }) - res = &model.Response{ - Message: `Please check your inbox! We have sent a password reset link.`, + if err != nil { + log.Debug("Failed to upsert sms otp: ", err) + return + } + + err = smsproviders.SendSMS(params.EmailOrPhone, smsBody.String()) + if err != nil { + log.Debug("Failed to send sms: ", err) + return + } + }() + + res = &model.Response{ + Message: `verification code has been sent to your phone`, + } + + return res, nil } return res, nil + } From cd61c71bbd9865f31749399d8cb8348766ed6f2e Mon Sep 17 00:00:00 2001 From: Mussie Teshome Date: Mon, 26 Jun 2023 14:31:44 +0300 Subject: [PATCH 4/6] reset password with phone verification --- server/resolvers/reset_password.go | 59 +++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/server/resolvers/reset_password.go b/server/resolvers/reset_password.go index 84976bb..e67c3a7 100644 --- a/server/resolvers/reset_password.go +++ b/server/resolvers/reset_password.go @@ -39,8 +39,63 @@ func ResetPasswordResolver(ctx context.Context, params model.ResetPasswordInput) log.Debug("Basic authentication is disabled") return res, fmt.Errorf(`basic authentication is disabled for this instance`) } + + if params.PhoneNumber != nil { + smsVerificationRequest, err := db.Provider.GetCodeByPhone(ctx, *params.PhoneNumber) - verificationRequest, err := db.Provider.GetVerificationRequestByToken(ctx, params.Token) + if err != nil { + log.Debug("Failed to get verification request: ", err) + return res, err + } + + if smsVerificationRequest == nil { + return res, fmt.Errorf(`phone number not found`) + } + + if smsVerificationRequest.Code != params.TokenOrCode { + log.Debug("Failed to verify request: bad credentials") + return res, fmt.Errorf(`bad credentials`) + } + + expiresIn := smsVerificationRequest.CodeExpiresAt - time.Now().Unix() + if expiresIn < 0 { + log.Debug("Failed to verify sms request: Timeout") + return res, fmt.Errorf("time expired") + } + + err = db.Provider.DeleteSMSRequest(ctx, smsVerificationRequest) + if err != nil { + log.Debug("Failed to delete sms request: ", err.Error()) + } + + user, err := db.Provider.GetUserByPhoneNumber(ctx, *params.PhoneNumber) + if err != nil { + log.Debug("Failed to get user: ", err) + return res, err + } + + password, _ := crypto.EncryptPassword(params.Password) + user.Password = &password + + if user.PhoneNumberVerifiedAt == nil { + now := time.Now().Unix() + user.PhoneNumberVerifiedAt = &now + } + + _, err = db.Provider.UpdateUser(ctx, *user) + if err != nil { + log.Debug("Failed to update user: ", err) + return res, err + } + + res = &model.Response{ + Message: `Password updated successfully.`, + } + + return res, nil + } + + verificationRequest, err := db.Provider.GetVerificationRequestByToken(ctx, params.TokenOrCode) if err != nil { log.Debug("Failed to get verification request: ", err) return res, fmt.Errorf(`invalid token`) @@ -58,7 +113,7 @@ func ResetPasswordResolver(ctx context.Context, params model.ResetPasswordInput) // verify if token exists in db hostname := parsers.GetHost(gc) - claim, err := token.ParseJWTToken(params.Token) + claim, err := token.ParseJWTToken(params.TokenOrCode) if err != nil { log.Debug("Failed to parse token: ", err) return res, fmt.Errorf(`invalid token`) From 22c269ebc812810ed66cd2e1f9e3a0e9b4f3f15f Mon Sep 17 00:00:00 2001 From: Mussie Teshome Date: Mon, 26 Jun 2023 14:53:38 +0300 Subject: [PATCH 5/6] forgot password test --- server/test/forgot_password_test.go | 31 ++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/server/test/forgot_password_test.go b/server/test/forgot_password_test.go index fd8a3bd..627f48f 100644 --- a/server/test/forgot_password_test.go +++ b/server/test/forgot_password_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/refs" "github.com/authorizerdev/authorizer/server/db" "github.com/authorizerdev/authorizer/server/graph/model" "github.com/authorizerdev/authorizer/server/resolvers" @@ -14,6 +15,7 @@ func forgotPasswordTest(t *testing.T, s TestSetup) { t.Helper() t.Run(`should run forgot password`, func(t *testing.T) { _, ctx := createContext(s) + phoneNumber := "2234567890" email := "forgot_password." + s.TestInfo.Email res, err := resolvers.SignupResolver(ctx, model.SignUpInput{ Email: email, @@ -23,7 +25,7 @@ func forgotPasswordTest(t *testing.T, s TestSetup) { assert.NoError(t, err) assert.NotNil(t, res) forgotPasswordRes, err := resolvers.ForgotPasswordResolver(ctx, model.ForgotPasswordInput{ - Email: email, + EmailOrPhone: email, }) assert.Nil(t, err, "no errors for forgot password") assert.NotNil(t, forgotPasswordRes) @@ -32,6 +34,33 @@ func forgotPasswordTest(t *testing.T, s TestSetup) { assert.Equal(t, verificationRequest.Identifier, constants.VerificationTypeForgotPassword) + // Signup using phone and forget password + signUpRes, err := resolvers.MobileSignupResolver(ctx, &model.MobileSignUpInput{ + Email: refs.NewStringRef(email), + PhoneNumber: phoneNumber, + Password: s.TestInfo.Password, + ConfirmPassword: s.TestInfo.Password, + }) + assert.NoError(t, err) + assert.NotNil(t, signUpRes) + + smsRequest, err := db.Provider.GetCodeByPhone(ctx, phoneNumber) + assert.NoError(t, err) + assert.NotEmpty(t, smsRequest.Code) + + verifySMSRequest, err := resolvers.VerifyMobileResolver(ctx, model.VerifyMobileRequest{ + PhoneNumber: phoneNumber, + Code: smsRequest.Code, + }) + assert.Nil(t, err) + assert.NotEqual(t, verifySMSRequest.Message, "", "message should not be empty") + + forgotPasswordWithPhone, err := resolvers.ForgotPasswordResolver(ctx, model.ForgotPasswordInput{ + EmailOrPhone: phoneNumber, + }) + assert.Nil(t, err) + assert.NotNil(t, forgotPasswordWithPhone, "verification code has been sent to your phone") + cleanData(email) }) } From 2d37d57e4f5bff07e2d49fff90859d633c6fb3a1 Mon Sep 17 00:00:00 2001 From: Mussie Teshome Date: Mon, 26 Jun 2023 14:53:57 +0300 Subject: [PATCH 6/6] reset password test --- server/test/reset_password_test.go | 51 ++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/server/test/reset_password_test.go b/server/test/reset_password_test.go index 8b0aa6f..7e72da4 100644 --- a/server/test/reset_password_test.go +++ b/server/test/reset_password_test.go @@ -3,6 +3,7 @@ package test import ( "testing" + "github.com/authorizerdev/authorizer/server/refs" "github.com/authorizerdev/authorizer/server/constants" "github.com/authorizerdev/authorizer/server/db" "github.com/authorizerdev/authorizer/server/graph/model" @@ -13,6 +14,8 @@ import ( func resetPasswordTest(t *testing.T, s TestSetup) { t.Helper() t.Run(`should reset password`, func(t *testing.T) { + phoneNumber := "2234567890" + phonePointer := &phoneNumber email := "reset_password." + s.TestInfo.Email _, ctx := createContext(s) _, err := resolvers.SignupResolver(ctx, model.SignUpInput{ @@ -22,7 +25,7 @@ func resetPasswordTest(t *testing.T, s TestSetup) { }) assert.NoError(t, err) _, err = resolvers.ForgotPasswordResolver(ctx, model.ForgotPasswordInput{ - Email: email, + EmailOrPhone: email, }) assert.Nil(t, err, "no errors for forgot password") @@ -30,7 +33,7 @@ func resetPasswordTest(t *testing.T, s TestSetup) { assert.Nil(t, err, "should get forgot password request") assert.NotNil(t, verificationRequest) _, err = resolvers.ResetPasswordResolver(ctx, model.ResetPasswordInput{ - Token: verificationRequest.Token, + TokenOrCode: verificationRequest.Token, Password: "test1", ConfirmPassword: "test", }) @@ -38,7 +41,7 @@ func resetPasswordTest(t *testing.T, s TestSetup) { assert.NotNil(t, err, "passowrds don't match") _, err = resolvers.ResetPasswordResolver(ctx, model.ResetPasswordInput{ - Token: verificationRequest.Token, + TokenOrCode: verificationRequest.Token, Password: "test1", ConfirmPassword: "test1", }) @@ -46,13 +49,49 @@ func resetPasswordTest(t *testing.T, s TestSetup) { assert.NotNil(t, err, "invalid password") _, err = resolvers.ResetPasswordResolver(ctx, model.ResetPasswordInput{ - Token: verificationRequest.Token, - Password: "Test@1234", - ConfirmPassword: "Test@1234", + TokenOrCode: verificationRequest.Token, + Password: "Test@1234", + ConfirmPassword: "Test@1234", }) assert.Nil(t, err, "password changed successfully") + // Signup with phone, forget password and then - reset it. + signUpRes, err := resolvers.MobileSignupResolver(ctx, &model.MobileSignUpInput{ + Email: refs.NewStringRef(email), + PhoneNumber: phoneNumber, + Password: s.TestInfo.Password, + ConfirmPassword: s.TestInfo.Password, + }) + assert.NoError(t, err) + assert.NotNil(t, signUpRes) + + forgotPasswordWithPhone, err := resolvers.ForgotPasswordResolver(ctx, model.ForgotPasswordInput{ + EmailOrPhone: phoneNumber, + }) + assert.Nil(t, err) + assert.NotNil(t, forgotPasswordWithPhone) + + // get code from db + smsRequestForReset, err := db.Provider.GetCodeByPhone(ctx, phoneNumber) + assert.Nil(t, err) + assert.NotNil(t, smsRequestForReset) + + // throw an error if the code is not correct + resetPasswordResponse, err := resolvers.ResetPasswordResolver(ctx, model.ResetPasswordInput{ + PhoneNumber: phonePointer, + TokenOrCode: "abcd@EFG", + }) + assert.NotNil(t, err, "should fail because of bad credentials") + assert.Nil(t, resetPasswordResponse) + + resetPasswordResponse, err = resolvers.ResetPasswordResolver(ctx, model.ResetPasswordInput{ + PhoneNumber: phonePointer, + TokenOrCode: smsRequestForReset.Code, + }) + assert.Nil(t, err) + assert.NotNil(t, resetPasswordResponse) + cleanData(email) }) }