diff --git a/README.md b/README.md index 587ca5d..0b50caf 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Deploy production ready Authorizer instance using one click deployment options a | Heroku | Deploy to Heroku | [docs](https://docs.authorizer.dev/deployment/heroku) | | Render | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/authorizerdev/authorizer-render) | [docs](https://docs.authorizer.dev/deployment/render) | | Koyeb | Deploy to Koyeb | [docs](https://docs.authorizer.dev/deployment/koyeb) | +| RepoCloud | Deploy on RepoCloud | [docs](https://repocloud.io/details/?app_id=174) | ### Deploy Authorizer Using Source Code diff --git a/server/resolvers/update_env.go b/server/resolvers/update_env.go index e7ceb70..62bd33e 100644 --- a/server/resolvers/update_env.go +++ b/server/resolvers/update_env.go @@ -7,6 +7,7 @@ import ( "fmt" "reflect" "strings" + "time" log "github.com/sirupsen/logrus" @@ -93,6 +94,53 @@ func clearSessionIfRequired(currentData, updatedData map[string]interface{}) { } } +// updateRoles will update DB for user roles, if a role is deleted by admin +// then this function will those roles from user roles if exists +func updateRoles(ctx context.Context, deletedRoles []string) error { + data, err := db.Provider.ListUsers(ctx, &model.Pagination{ + Limit: 1, + Offset: 1, + }) + if err != nil { + return err + } + + allData, err := db.Provider.ListUsers(ctx, &model.Pagination{ + Limit: data.Pagination.Total, + }) + if err != nil { + return err + } + + chunkSize := 1000 + totalUsers := len(allData.Users) + + for start := 0; start < totalUsers; start += chunkSize { + end := start + chunkSize + if end > totalUsers { + end = totalUsers + } + + chunkUsers := allData.Users[start:end] + + for i := range chunkUsers { + roles := utils.DeleteFromArray(chunkUsers[i].Roles, deletedRoles) + if len(chunkUsers[i].Roles) != len(roles) { + updatedValues := map[string]interface{}{ + "roles": strings.Join(roles, ","), + "updated_at": time.Now().Unix(), + } + id := []string{chunkUsers[i].ID} + err = db.Provider.UpdateUsers(ctx, updatedValues, id) + if err != nil { + return err + } + } + } + } + return nil +} + // UpdateEnvResolver is a resolver for update config mutation // This is admin only mutation func UpdateEnvResolver(ctx context.Context, params model.UpdateEnvInput) (*model.Response, error) { @@ -291,28 +339,41 @@ func UpdateEnvResolver(ctx context.Context, params model.UpdateEnvInput) (*model }, nil) } + previousRoles := strings.Split(currentData[constants.EnvKeyRoles].(string), ",") + previousProtectedRoles := strings.Split(currentData[constants.EnvKeyProtectedRoles].(string), ",") + updatedRoles := strings.Split(updatedData[constants.EnvKeyRoles].(string), ",") + updatedDefaultRoles := strings.Split(updatedData[constants.EnvKeyDefaultRoles].(string), ",") + updatedProtectedRoles := strings.Split(updatedData[constants.EnvKeyProtectedRoles].(string), ",") // check the roles change - if len(params.Roles) > 0 { - if len(params.DefaultRoles) > 0 { - // should be subset of roles - for _, role := range params.DefaultRoles { - if !utils.StringSliceContains(params.Roles, role) { - log.Debug("Default roles should be subset of roles") - return res, fmt.Errorf("default role %s is not in roles", role) - } + if len(updatedRoles) > 0 && len(updatedDefaultRoles) > 0 { + // should be subset of roles + for _, role := range updatedDefaultRoles { + if !utils.StringSliceContains(updatedRoles, role) { + log.Debug("Default roles should be subset of roles") + return res, fmt.Errorf("default role %s is not in roles", role) } } } - if len(params.ProtectedRoles) > 0 { - for _, role := range params.ProtectedRoles { - if utils.StringSliceContains(params.Roles, role) || utils.StringSliceContains(params.DefaultRoles, role) { + if len(updatedProtectedRoles) > 0 { + for _, role := range updatedProtectedRoles { + if utils.StringSliceContains(updatedRoles, role) || utils.StringSliceContains(updatedDefaultRoles, role) { log.Debug("Protected roles should not be in roles or default roles") return res, fmt.Errorf("protected role %s found roles or default roles", role) } } } + deletedRoles := utils.FindDeletedValues(previousRoles, updatedRoles) + if len(deletedRoles) > 0 { + go updateRoles(ctx, deletedRoles) + } + + deletedProtectedRoles := utils.FindDeletedValues(previousProtectedRoles, updatedProtectedRoles) + if len(deletedProtectedRoles) > 0 { + go updateRoles(ctx, deletedProtectedRoles) + } + // Update local store memorystore.Provider.UpdateEnvStore(updatedData) jwk, err := crypto.GenerateJWKBasedOnEnv() diff --git a/server/resolvers/verify_email.go b/server/resolvers/verify_email.go index d263629..3ad399e 100644 --- a/server/resolvers/verify_email.go +++ b/server/resolvers/verify_email.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" log "github.com/sirupsen/logrus" + "github.com/authorizerdev/authorizer/server/authenticators" "github.com/authorizerdev/authorizer/server/constants" "github.com/authorizerdev/authorizer/server/cookie" "github.com/authorizerdev/authorizer/server/db" @@ -60,6 +61,66 @@ func VerifyEmailResolver(ctx context.Context, params model.VerifyEmailInput) (*m return res, err } + isMFADisabled, err := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisableMultiFactorAuthentication) + if err != nil || !isMFADisabled { + log.Debug("MFA service not enabled: ", err) + } + + isTOTPLoginDisabled, err := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisableTOTPLogin) + if err != nil || !isTOTPLoginDisabled { + log.Debug("totp service not enabled: ", err) + } + + setOTPMFaSession := func(expiresAt int64) error { + mfaSession := uuid.NewString() + err = memorystore.Provider.SetMfaSession(user.ID, mfaSession, expiresAt) + if err != nil { + log.Debug("Failed to add mfasession: ", err) + return err + } + cookie.SetMfaSession(gc, mfaSession) + return nil + } + + // If mfa enabled and also totp enabled + if refs.BoolValue(user.IsMultiFactorAuthEnabled) && !isMFADisabled && !isTOTPLoginDisabled { + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := setOTPMFaSession(expiresAt); err != nil { + log.Debug("Failed to set mfa session: ", err) + return nil, err + } + authenticator, err := db.Provider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + if err != nil || authenticator == nil || authenticator.VerifiedAt == nil { + // generate totp + // Generate a base64 URL and initiate the registration for TOTP + authConfig, err := authenticators.Provider.Generate(ctx, user.ID) + if err != nil { + log.Debug("error while generating base64 url: ", err) + return nil, err + } + recoveryCodes := []*string{} + for _, code := range authConfig.RecoveryCodes { + recoveryCodes = append(recoveryCodes, refs.NewStringRef(code)) + } + // when user is first time registering for totp + res = &model.AuthResponse{ + Message: `Proceed to totp verification screen`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + AuthenticatorScannerImage: refs.NewStringRef(authConfig.ScannerImage), + AuthenticatorSecret: refs.NewStringRef(authConfig.Secret), + AuthenticatorRecoveryCodes: recoveryCodes, + } + return res, nil + } else { + //when user is already register for totp + res = &model.AuthResponse{ + Message: `Proceed to totp screen`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + } + return res, nil + } + } + isSignUp := false if user.EmailVerifiedAt == nil { isSignUp = true diff --git a/server/test/integration_test.go b/server/test/integration_test.go index e490a14..5329e99 100644 --- a/server/test/integration_test.go +++ b/server/test/integration_test.go @@ -122,6 +122,7 @@ func TestResolvers(t *testing.T) { updateEmailTemplateTest(t, s) emailTemplatesTest(t, s) deleteEmailTemplateTest(t, s) + RoleDeletionTest(t, s) // user resolvers tests loginTests(t, s) @@ -129,6 +130,7 @@ func TestResolvers(t *testing.T) { mobileSingupTest(t, s) mobileLoginTests(t, s) totpLoginTest(t, s) + totpSignupTest(t, s) forgotPasswordTest(t, s) forgotPasswordMobileTest(t, s) resendVerifyEmailTests(t, s) diff --git a/server/test/role_deletion_test.go b/server/test/role_deletion_test.go new file mode 100644 index 0000000..ed0ed90 --- /dev/null +++ b/server/test/role_deletion_test.go @@ -0,0 +1,98 @@ +package test + +import ( + "fmt" + "github.com/authorizerdev/authorizer/server/crypto" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/memorystore" + "github.com/authorizerdev/authorizer/server/refs" + "github.com/authorizerdev/authorizer/server/resolvers" +) + +func RoleDeletionTest(t *testing.T, s TestSetup) { + t.Helper() + t.Run(`should complete role deletion`, func(t *testing.T) { + // login as admin + req, ctx := createContext(s) + + _, err := resolvers.AdminLoginResolver(ctx, model.AdminLoginInput{ + AdminSecret: "admin_test", + }) + assert.NotNil(t, err) + + adminSecret, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyAdminSecret) + assert.Nil(t, err) + _, err = resolvers.AdminLoginResolver(ctx, model.AdminLoginInput{ + AdminSecret: adminSecret, + }) + assert.Nil(t, err) + + h, err := crypto.EncryptPassword(adminSecret) + assert.Nil(t, err) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.AdminCookieName, h)) + + // add new default role to get role, if not present in roles + originalDefaultRoles, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyDefaultRoles) + assert.Nil(t, err) + originalDefaultRolesSlice := strings.Split(originalDefaultRoles, ",") + + data := model.UpdateEnvInput{ + DefaultRoles: append(originalDefaultRolesSlice, "abc"), + } + _, err = resolvers.UpdateEnvResolver(ctx, data) + assert.Error(t, err) + + // add new role + originalRoles, err := memorystore.Provider.GetStringStoreEnvVariable(constants.EnvKeyRoles) + assert.Nil(t, err) + originalRolesSlice := strings.Split(originalRoles, ",") + roleToBeAdded := "abc" + newRoles := append(originalRolesSlice, roleToBeAdded) + data = model.UpdateEnvInput{ + Roles: newRoles, + } + _, err = resolvers.UpdateEnvResolver(ctx, data) + assert.Nil(t, err) + + // register a user with all roles + email := "update_user." + s.TestInfo.Email + _, err = resolvers.SignupResolver(ctx, model.SignUpInput{ + Email: refs.NewStringRef(email), + Password: s.TestInfo.Password, + ConfirmPassword: s.TestInfo.Password, + Roles: newRoles, + }) + assert.Nil(t, err) + + regUserDetails, _ := resolvers.UserResolver(ctx, model.GetUserRequest{ + Email: refs.NewStringRef(email), + }) + + // update env by removing role "abc" + var newRolesAfterDeletion []string + for _, value := range newRoles { + if value != roleToBeAdded { + newRolesAfterDeletion = append(newRolesAfterDeletion, value) + } + } + data = model.UpdateEnvInput{ + Roles: newRolesAfterDeletion, + } + _, err = resolvers.UpdateEnvResolver(ctx, data) + assert.Nil(t, err) + + // check user if role still exist + userDetails, err := resolvers.UserResolver(ctx, model.GetUserRequest{ + Email: refs.NewStringRef(email), + }) + assert.Nil(t, err) + assert.Equal(t, newRolesAfterDeletion, userDetails.Roles) + assert.NotEqual(t, newRolesAfterDeletion, regUserDetails.Roles) + }) +} diff --git a/server/test/signup_test.go b/server/test/signup_test.go index 4706573..ec8cdb3 100644 --- a/server/test/signup_test.go +++ b/server/test/signup_test.go @@ -37,7 +37,7 @@ func signupTests(t *testing.T, s TestSetup) { Password: s.TestInfo.Password, ConfirmPassword: s.TestInfo.Password, }) - assert.NotNil(t, err, "singup disabled") + assert.NotNil(t, err, "signup disabled") assert.Nil(t, res) memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableSignUp, false) res, err = resolvers.SignupResolver(ctx, model.SignUpInput{ diff --git a/server/test/totp_login_test.go b/server/test/totp_login_test.go index 11b992f..8eef795 100644 --- a/server/test/totp_login_test.go +++ b/server/test/totp_login_test.go @@ -92,6 +92,7 @@ func totpLoginTest(t *testing.T, s TestSetup) { assert.NotNil(t, tf) code := tf.OTP() assert.NotEmpty(t, code) + // Set mfa cookie session mfaSession := uuid.NewString() memorystore.Provider.SetMfaSession(verifyRes.User.ID, mfaSession, time.Now().Add(1*time.Minute).Unix()) @@ -122,6 +123,7 @@ func totpLoginTest(t *testing.T, s TestSetup) { cookie = fmt.Sprintf("%s=%s;", constants.AppCookieName+"_session", sessionToken) cookie = strings.TrimSuffix(cookie, ";") req.Header.Set("Cookie", cookie) + //logged out logout, err := resolvers.LogoutResolver(ctx) assert.NoError(t, err) diff --git a/server/test/totp_signup_test.go b/server/test/totp_signup_test.go new file mode 100644 index 0000000..6dc5a0d --- /dev/null +++ b/server/test/totp_signup_test.go @@ -0,0 +1,187 @@ +package test + +import ( + "bytes" + "encoding/base64" + "fmt" + "strings" + "testing" + "time" + + "github.com/authorizerdev/authorizer/server/authenticators" + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/db" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/memorystore" + "github.com/authorizerdev/authorizer/server/refs" + "github.com/authorizerdev/authorizer/server/resolvers" + "github.com/authorizerdev/authorizer/server/token" + "github.com/gokyle/twofactor" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/tuotoo/qrcode" +) + +func totpSignupTest(t *testing.T, s TestSetup) { + t.Helper() + // Test case to verify TOTP for signup + t.Run(`should verify totp for signup`, func(t *testing.T) { + // Create request and context using test setup + req, ctx := createContext(s) + email := "verify_totp." + s.TestInfo.Email + + // Test case: Invalid password (confirm password mismatch) + res, err := resolvers.SignupResolver(ctx, model.SignUpInput{ + Email: refs.NewStringRef(email), + Password: s.TestInfo.Password, + ConfirmPassword: s.TestInfo.Password + "s", + }) + assert.NotNil(t, err, "invalid password") + assert.Nil(t, res) + + { + // Test case: Invalid password ("test" as the password) + res, err = resolvers.SignupResolver(ctx, model.SignUpInput{ + Email: refs.NewStringRef(email), + Password: "test", + ConfirmPassword: "test", + }) + assert.NotNil(t, err, "invalid password") + assert.Nil(t, res) + } + + { + // Test case: Signup disabled + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableSignUp, true) + res, err = resolvers.SignupResolver(ctx, model.SignUpInput{ + Email: refs.NewStringRef(email), + Password: s.TestInfo.Password, + ConfirmPassword: s.TestInfo.Password, + }) + assert.NotNil(t, err, "signup disabled") + assert.Nil(t, res) + } + + { + // Test case: Successful signup + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableSignUp, false) + res, err = resolvers.SignupResolver(ctx, model.SignUpInput{ + Email: refs.NewStringRef(email), + Password: s.TestInfo.Password, + ConfirmPassword: s.TestInfo.Password, + AppData: map[string]interface{}{ + "test": "test", + }, + }) + assert.Nil(t, err, "signup should be successful") + user := *res.User + assert.Equal(t, email, refs.StringValue(user.Email)) + assert.Equal(t, "test", user.AppData["test"]) + assert.Nil(t, res.AccessToken, "access token should be nil") + } + + { + // Test case: Duplicate email (should throw an error) + res, err = resolvers.SignupResolver(ctx, model.SignUpInput{ + Email: refs.NewStringRef(email), + Password: s.TestInfo.Password, + ConfirmPassword: s.TestInfo.Password, + }) + assert.NotNil(t, err, "should throw duplicate email error") + assert.Nil(t, res) + } + + // Clean up data for the email + cleanData(email) + + { + // Test case: Email verification and TOTP setup + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableEmailVerification, false) + + // Sign up a user + res, err := resolvers.SignupResolver(ctx, model.SignUpInput{ + Email: refs.NewStringRef(email), + Password: s.TestInfo.Password, + ConfirmPassword: s.TestInfo.Password, + }) + assert.Nil(t, err, "Expected no error but got: %v", err) + assert.Equal(t, "Verification email has been sent. Please check your inbox", res.Message) + + // Retrieve user and update for TOTP setup + user, err := db.Provider.GetUserByID(ctx, res.User.ID) + assert.Nil(t, err, "Expected no error but got: %v", err) + assert.NotNil(t, user) + + // Enable multi-factor authentication and update the user + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableTOTPLogin, false) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + updatedUser, err := db.Provider.UpdateUser(ctx, user) + assert.Nil(t, err, "Expected no error but got: %v", err) + assert.Equal(t, true, *updatedUser.IsMultiFactorAuthEnabled) + + // Initialise totp authenticator store + authenticators.InitTOTPStore() + + // Verify an email and get TOTP response + verificationRequest, err := db.Provider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeBasicAuthSignup) + assert.Nil(t, err) + assert.Equal(t, email, verificationRequest.Email) + verifyRes, err := resolvers.VerifyEmailResolver(ctx, model.VerifyEmailInput{ + Token: verificationRequest.Token, + }) + assert.Nil(t, err, "Expected no error but got: %v", err) + assert.NotNil(t, &verifyRes) + assert.Nil(t, verifyRes.AccessToken) + assert.Equal(t, "Proceed to totp verification screen", verifyRes.Message) + assert.NotEqual(t, *verifyRes.AuthenticatorScannerImage, "", "totp url should not be empty") + assert.NotEqual(t, *verifyRes.AuthenticatorSecret, "", "totp secret should not be empty") + assert.NotNil(t, verifyRes.AuthenticatorRecoveryCodes) + + // Get TOTP URL for for validation + pngBytes, err := base64.StdEncoding.DecodeString(*verifyRes.AuthenticatorScannerImage) + assert.NoError(t, err) + qrmatrix, err := qrcode.Decode(bytes.NewReader(pngBytes)) + assert.NoError(t, err) + tf, label, err := twofactor.FromURL(qrmatrix.Content) + data := strings.Split(label, ":") + assert.NoError(t, err) + assert.Equal(t, email, data[1]) + assert.NotNil(t, tf) + code := tf.OTP() + assert.NotEmpty(t, code) + + // Set MFA cookie session + mfaSession := uuid.NewString() + memorystore.Provider.SetMfaSession(res.User.ID, mfaSession, time.Now().Add(1*time.Minute).Unix()) + cookie := fmt.Sprintf("%s=%s;", constants.MfaCookieName+"_session", mfaSession) + cookie = strings.TrimSuffix(cookie, ";") + req.Header.Set("Cookie", cookie) + valid, err := resolvers.VerifyOtpResolver(ctx, model.VerifyOTPRequest{ + Email: &email, + IsTotp: refs.NewBoolRef(true), + Otp: code, + }) + accessToken := *valid.AccessToken + assert.NoError(t, err) + assert.NotNil(t, accessToken) + assert.NotEmpty(t, valid.Message) + assert.NotEmpty(t, accessToken) + claims, err := token.ParseJWTToken(accessToken) + assert.NoError(t, err) + assert.NotEmpty(t, claims) + signUpMethod := claims["login_method"] + sessionKey := res.User.ID + if signUpMethod != nil && signUpMethod != "" { + sessionKey = signUpMethod.(string) + ":" + res.User.ID + } + sessionToken, err := memorystore.Provider.GetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+claims["nonce"].(string)) + assert.NoError(t, err) + assert.NotEmpty(t, sessionToken) + cookie = fmt.Sprintf("%s=%s;", constants.AppCookieName+"_session", sessionToken) + cookie = strings.TrimSuffix(cookie, ";") + req.Header.Set("Cookie", cookie) + } + // Clean up data for the email + cleanData(email) + }) +} diff --git a/server/test/verify_otp_test.go b/server/test/verify_otp_test.go index c01968d..c965932 100644 --- a/server/test/verify_otp_test.go +++ b/server/test/verify_otp_test.go @@ -1,92 +1,202 @@ package test import ( + "bytes" "context" + "encoding/base64" "fmt" "strings" "testing" "time" + "github.com/gokyle/twofactor" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/tuotoo/qrcode" + + "github.com/authorizerdev/authorizer/server/authenticators" "github.com/authorizerdev/authorizer/server/constants" "github.com/authorizerdev/authorizer/server/db" "github.com/authorizerdev/authorizer/server/graph/model" "github.com/authorizerdev/authorizer/server/memorystore" "github.com/authorizerdev/authorizer/server/refs" "github.com/authorizerdev/authorizer/server/resolvers" - "github.com/google/uuid" - "github.com/stretchr/testify/assert" + "github.com/authorizerdev/authorizer/server/token" ) func verifyOTPTest(t *testing.T, s TestSetup) { t.Helper() t.Run(`should verify otp`, func(t *testing.T) { + // Set up request and context using test setup req, ctx := createContext(s) email := "verify_otp." + s.TestInfo.Email - res, err := resolvers.SignupResolver(ctx, model.SignUpInput{ - Email: refs.NewStringRef(email), - Password: s.TestInfo.Password, - ConfirmPassword: s.TestInfo.Password, - }) - assert.NoError(t, err) - assert.NotNil(t, res) - // Login should fail as email is not verified - loginRes, err := resolvers.LoginResolver(ctx, model.LoginInput{ - Email: refs.NewStringRef(email), - Password: s.TestInfo.Password, - }) - assert.Error(t, err) - assert.Nil(t, loginRes) - verificationRequest, err := db.Provider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeBasicAuthSignup) - assert.Nil(t, err) - assert.Equal(t, email, verificationRequest.Email) - verifyRes, err := resolvers.VerifyEmailResolver(ctx, model.VerifyEmailInput{ - Token: verificationRequest.Token, - }) - assert.Nil(t, err) - assert.NotEqual(t, verifyRes.AccessToken, "", "access token should not be empty") + // Test case: Setup email OTP MFA for login + { + // Sign up a user + res, err := resolvers.SignupResolver(ctx, model.SignUpInput{ + Email: refs.NewStringRef(email), + Password: s.TestInfo.Password, + ConfirmPassword: s.TestInfo.Password, + }) + assert.NoError(t, err) + assert.NotNil(t, res) - // Using access token update profile - s.GinContext.Request.Header.Set("Authorization", "Bearer "+refs.StringValue(verifyRes.AccessToken)) - ctx = context.WithValue(req.Context(), "GinContextKey", s.GinContext) - memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableMailOTPLogin, false) - memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableTOTPLogin, true) - memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisablePhoneVerification, true) - updateProfileRes, err := resolvers.UpdateProfileResolver(ctx, model.UpdateProfileInput{ - IsMultiFactorAuthEnabled: refs.NewBoolRef(true), - }) - assert.NoError(t, err) - assert.NotEmpty(t, updateProfileRes.Message) + // Attempt to login should fail as email is not verified + loginRes, err := resolvers.LoginResolver(ctx, model.LoginInput{ + Email: refs.NewStringRef(email), + Password: s.TestInfo.Password, + }) + assert.NotNil(t, err, "email is not verified") + assert.Nil(t, loginRes) - // Login should not return error but access token should be empty as otp should have been sent - loginRes, err = resolvers.LoginResolver(ctx, model.LoginInput{ - Email: refs.NewStringRef(email), - Password: s.TestInfo.Password, - }) - assert.NoError(t, err) - assert.NotNil(t, loginRes) - assert.Nil(t, loginRes.AccessToken) + // Verify the email + verificationRequest, err := db.Provider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeBasicAuthSignup) + assert.Nil(t, err) + assert.Equal(t, email, verificationRequest.Email) + verifyRes, err := resolvers.VerifyEmailResolver(ctx, model.VerifyEmailInput{ + Token: verificationRequest.Token, + }) + assert.Nil(t, err) + assert.NotEqual(t, verifyRes.AccessToken, "", "access token should not be empty") - // Get otp from db - otp, err := db.Provider.GetOTPByEmail(ctx, email) - assert.NoError(t, err) - assert.NotEmpty(t, otp.Otp) - // Get user by email - user, err := db.Provider.GetUserByEmail(ctx, email) - assert.NoError(t, err) - assert.NotNil(t, user) - // Set mfa cookie session - mfaSession := uuid.NewString() - memorystore.Provider.SetMfaSession(user.ID, mfaSession, time.Now().Add(1*time.Minute).Unix()) - cookie := fmt.Sprintf("%s=%s;", constants.MfaCookieName+"_session", mfaSession) - cookie = strings.TrimSuffix(cookie, ";") - req.Header.Set("Cookie", cookie) - verifyOtpRes, err := resolvers.VerifyOtpResolver(ctx, model.VerifyOTPRequest{ - Email: &email, - Otp: otp.Otp, - }) - assert.Nil(t, err) - assert.NotEqual(t, verifyOtpRes.AccessToken, "", "access token should not be empty") - cleanData(email) + // Use access token to update the profile + s.GinContext.Request.Header.Set("Authorization", "Bearer "+refs.StringValue(verifyRes.AccessToken)) + ctx = context.WithValue(req.Context(), "GinContextKey", s.GinContext) + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableMailOTPLogin, false) + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableTOTPLogin, true) + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisablePhoneVerification, true) + updateProfileRes, err := resolvers.UpdateProfileResolver(ctx, model.UpdateProfileInput{ + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + assert.NoError(t, err) + assert.NotEmpty(t, updateProfileRes.Message) + + // Login should not return an error, but the access token should be empty as OTP should have been sent + loginRes, err = resolvers.LoginResolver(ctx, model.LoginInput{ + Email: refs.NewStringRef(email), + Password: s.TestInfo.Password, + }) + assert.NoError(t, err) + assert.NotNil(t, loginRes) + assert.Nil(t, loginRes.AccessToken) + + // Get OTP from db + otp, err := db.Provider.GetOTPByEmail(ctx, email) + assert.NoError(t, err) + assert.NotEmpty(t, otp.Otp) + + // Get user by email + user, err := db.Provider.GetUserByEmail(ctx, email) + assert.NoError(t, err) + assert.NotNil(t, user) + + // Set MFA cookie session + mfaSession := uuid.NewString() + memorystore.Provider.SetMfaSession(user.ID, mfaSession, time.Now().Add(1*time.Minute).Unix()) + cookie := fmt.Sprintf("%s=%s;", constants.MfaCookieName+"_session", mfaSession) + cookie = strings.TrimSuffix(cookie, ";") + req.Header.Set("Cookie", cookie) + + // Verify OTP + verifyOtpRes, err := resolvers.VerifyOtpResolver(ctx, model.VerifyOTPRequest{ + Email: &email, + Otp: otp.Otp, + }) + assert.Nil(t, err) + assert.NotEqual(t, verifyOtpRes.AccessToken, "", "access token should not be empty") + + // Clean up data for the email + cleanData(email) + } + + // Test case: Setup TOTP MFA for signup + { + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableEmailVerification, false) + signUpRes, err := resolvers.SignupResolver(ctx, model.SignUpInput{ + Email: refs.NewStringRef(email), + Password: s.TestInfo.Password, + ConfirmPassword: s.TestInfo.Password, + }) + assert.Nil(t, err, "Expected no error but got: %v", err) + assert.Equal(t, "Verification email has been sent. Please check your inbox", signUpRes.Message) + + // Retrieve user and update for TOTP setup + user, err := db.Provider.GetUserByID(ctx, signUpRes.User.ID) + assert.Nil(t, err, "Expected no error but got: %v", err) + assert.NotNil(t, user) + + // Enable multi-factor authentication and update the user + memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableTOTPLogin, false) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + updatedUser, err := db.Provider.UpdateUser(ctx, user) + assert.Nil(t, err, "Expected no error but got: %v", err) + assert.Equal(t, true, *updatedUser.IsMultiFactorAuthEnabled) + + // Initialise totp authenticator store + authenticators.InitTOTPStore() + + // Verify an email and get TOTP response + verificationRequest, err := db.Provider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeBasicAuthSignup) + assert.Nil(t, err) + assert.Equal(t, email, verificationRequest.Email) + verifyRes, err := resolvers.VerifyEmailResolver(ctx, model.VerifyEmailInput{ + Token: verificationRequest.Token, + }) + assert.Nil(t, err, "Expected no error but got: %v", err) + assert.NotNil(t, &verifyRes) + assert.Nil(t, verifyRes.AccessToken) + assert.Equal(t, "Proceed to totp verification screen", verifyRes.Message) + assert.NotEqual(t, *verifyRes.AuthenticatorScannerImage, "", "totp url should not be empty") + assert.NotEqual(t, *verifyRes.AuthenticatorSecret, "", "totp secret should not be empty") + assert.NotNil(t, verifyRes.AuthenticatorRecoveryCodes) + + // Get TOTP URL for validation + pngBytes, err := base64.StdEncoding.DecodeString(*verifyRes.AuthenticatorScannerImage) + assert.NoError(t, err) + qrmatrix, err := qrcode.Decode(bytes.NewReader(pngBytes)) + assert.NoError(t, err) + tf, label, err := twofactor.FromURL(qrmatrix.Content) + data := strings.Split(label, ":") + assert.NoError(t, err) + assert.Equal(t, email, data[1]) + assert.NotNil(t, tf) + code := tf.OTP() + assert.NotEmpty(t, code) + + // Set mfa cookie session + mfaSession := uuid.NewString() + memorystore.Provider.SetMfaSession(signUpRes.User.ID, mfaSession, time.Now().Add(1*time.Minute).Unix()) + cookie := fmt.Sprintf("%s=%s;", constants.MfaCookieName+"_session", mfaSession) + cookie = strings.TrimSuffix(cookie, ";") + req.Header.Set("Cookie", cookie) + valid, err := resolvers.VerifyOtpResolver(ctx, model.VerifyOTPRequest{ + Email: &email, + IsTotp: refs.NewBoolRef(true), + Otp: code, + }) + accessToken := *valid.AccessToken + assert.NoError(t, err) + assert.NotNil(t, accessToken) + assert.NotEmpty(t, valid.Message) + assert.NotEmpty(t, accessToken) + claims, err := token.ParseJWTToken(accessToken) + assert.NoError(t, err) + assert.NotEmpty(t, claims) + signUpMethod := claims["login_method"] + sessionKey := signUpRes.User.ID + if signUpMethod != nil && signUpMethod != "" { + sessionKey = signUpMethod.(string) + ":" + signUpRes.User.ID + } + sessionToken, err := memorystore.Provider.GetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+claims["nonce"].(string)) + assert.NoError(t, err) + assert.NotEmpty(t, sessionToken) + cookie = fmt.Sprintf("%s=%s;", constants.AppCookieName+"_session", sessionToken) + cookie = strings.TrimSuffix(cookie, ";") + req.Header.Set("Cookie", cookie) + + // Clean up data for the email + cleanData(email) + } }) } diff --git a/server/utils/common.go b/server/utils/common.go index 642d859..b5f81b6 100644 --- a/server/utils/common.go +++ b/server/utils/common.go @@ -95,3 +95,43 @@ func GetInviteVerificationURL(verificationURL, token, redirectURI string) string func GetEmailVerificationURL(token, hostname, redirectURI string) string { return hostname + "/verify_email?token=" + token + "&redirect_uri=" + redirectURI } + +// FindDeletedValues find deleted values between original and updated one +func FindDeletedValues(original, updated []string) []string { + deletedValues := make([]string, 0) + + // Create a map to store elements of the updated array for faster lookups + updatedMap := make(map[string]bool) + for _, value := range updated { + updatedMap[value] = true + } + + // Check for deleted values in the original array + for _, value := range original { + if _, found := updatedMap[value]; !found { + deletedValues = append(deletedValues, value) + } + } + + return deletedValues +} + +// DeleteFromArray will delete array from an array +func DeleteFromArray(original, valuesToDelete []string) []string { + result := make([]string, 0) + + // Create a map to store values to delete for faster lookups + valuesToDeleteMap := make(map[string]bool) + for _, value := range valuesToDelete { + valuesToDeleteMap[value] = true + } + + // Check if each element in the original array should be deleted + for _, value := range original { + if _, found := valuesToDeleteMap[value]; !found { + result = append(result, value) + } + } + + return result +}