From 7d4c6412972e4c88be0af326c1488108e7060869 Mon Sep 17 00:00:00 2001 From: lemonScaletech Date: Fri, 24 Nov 2023 13:52:17 +0530 Subject: [PATCH 1/9] fix: * removed fmt.Println --- server/env/env.go | 1 - 1 file changed, 1 deletion(-) diff --git a/server/env/env.go b/server/env/env.go index 2aa08b3..bd713c3 100644 --- a/server/env/env.go +++ b/server/env/env.go @@ -848,7 +848,6 @@ func InitAllEnv() error { envData[constants.EnvKeyDisableTOTPLogin] = boolValue } } - fmt.Println("=> final value", envData[constants.EnvKeyDisableTOTPLogin]) if _, ok := envData[constants.EnvKeyDisableMailOTPLogin]; !ok { envData[constants.EnvKeyDisableMailOTPLogin] = osDisableMailOTPLogin == "true" From 7e9fac335bfd7855bc184e47e0a13d23e955285b Mon Sep 17 00:00:00 2001 From: scaletech-milan Date: Wed, 6 Dec 2023 15:53:01 +0530 Subject: [PATCH 2/9] Feat: - Add TOTP MFA for signup - Test cases for totp signup and verify_email --- server/resolvers/verify_email.go | 66 +++++++++ server/test/integration_test.go | 1 + server/test/signup_test.go | 2 +- server/test/totp_login_test.go | 2 + server/test/totp_signup_test.go | 187 ++++++++++++++++++++++++ server/test/verify_otp_test.go | 238 ++++++++++++++++++++++--------- 6 files changed, 431 insertions(+), 65 deletions(-) create mode 100644 server/test/totp_signup_test.go diff --git a/server/resolvers/verify_email.go b/server/resolvers/verify_email.go index d263629..d3b0e60 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,71 @@ 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) + } + + isMailOTPDisabled, err := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisableMailOTPLogin) + if err != nil || !isMailOTPDisabled { + log.Debug("mail OTP 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 587235c..e663709 100644 --- a/server/test/integration_test.go +++ b/server/test/integration_test.go @@ -129,6 +129,7 @@ func TestResolvers(t *testing.T) { mobileSingupTest(t, s) mobileLoginTests(t, s) totpLoginTest(t, s) + totpSignupTest(t, s) forgotPasswordTest(t, s) resendVerifyEmailTests(t, s) resetPasswordTest(t, s) 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) + } }) } From 5cb94a782032289d51b08960fc37d9c0adda48d6 Mon Sep 17 00:00:00 2001 From: lemonScaletech Date: Thu, 7 Dec 2023 19:33:59 +0530 Subject: [PATCH 3/9] fix: * added logic if role is deleted then also be deleted from user side if role is assigned to that user. * default role should be subset of roles --- server/resolvers/update_env.go | 61 ++++++++++++++++++++++++++++++---- server/utils/common.go | 40 ++++++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/server/resolvers/update_env.go b/server/resolvers/update_env.go index e7ceb70..4bda560 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,42 @@ func clearSessionIfRequired(currentData, updatedData map[string]interface{}) { } } +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 + } + + for i := range allData.Users { + now := time.Now().Unix() + allData.Users[i].Roles = utils.DeleteFromArray(allData.Users[i].Roles, deletedRoles) + allData.Users[i].UpdatedAt = &now + } + + for i := range allData.Users { + updatedValues := map[string]interface{}{ + "roles": strings.Join(allData.Users[i].Roles, ","), + "updated_at": time.Now().Unix(), + } + id := []string{allData.Users[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,12 +328,17 @@ func UpdateEnvResolver(ctx context.Context, params model.UpdateEnvInput) (*model }, nil) } + previousRoles := strings.Split(currentData[constants.EnvKeyRoles].(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 { + if len(updatedRoles) > 0 { + if len(updatedDefaultRoles) > 0 { // should be subset of roles - for _, role := range params.DefaultRoles { - if !utils.StringSliceContains(params.Roles, role) { + 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) } @@ -304,15 +346,20 @@ func UpdateEnvResolver(ctx context.Context, params model.UpdateEnvInput) (*model } } - 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) + } + // Update local store memorystore.Provider.UpdateEnvStore(updatedData) jwk, err := crypto.GenerateJWKBasedOnEnv() 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 +} From b8c2ab4cf8f8e8beaaaee96ee2810f961ddee3a7 Mon Sep 17 00:00:00 2001 From: lemonScaletech Date: Fri, 8 Dec 2023 10:38:09 +0530 Subject: [PATCH 4/9] refactoring: * removed extra for loop * commenting on functions --- server/resolvers/update_env.go | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/server/resolvers/update_env.go b/server/resolvers/update_env.go index 4bda560..68087b4 100644 --- a/server/resolvers/update_env.go +++ b/server/resolvers/update_env.go @@ -94,6 +94,8 @@ 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, @@ -111,20 +113,17 @@ func updateRoles(ctx context.Context, deletedRoles []string) error { } for i := range allData.Users { - now := time.Now().Unix() - allData.Users[i].Roles = utils.DeleteFromArray(allData.Users[i].Roles, deletedRoles) - allData.Users[i].UpdatedAt = &now - } - - for i := range allData.Users { - updatedValues := map[string]interface{}{ - "roles": strings.Join(allData.Users[i].Roles, ","), - "updated_at": time.Now().Unix(), - } - id := []string{allData.Users[i].ID} - err = db.Provider.UpdateUsers(ctx, updatedValues, id) - if err != nil { - return err + roles := utils.DeleteFromArray(allData.Users[i].Roles, deletedRoles) + if len(allData.Users[i].Roles) != len(roles) { + updatedValues := map[string]interface{}{ + "roles": strings.Join(roles, ","), + "updated_at": time.Now().Unix(), + } + id := []string{allData.Users[i].ID} + err = db.Provider.UpdateUsers(ctx, updatedValues, id) + if err != nil { + return err + } } } return nil From 47f26103b0d732dc399f9ee24294edff9bdcb9c1 Mon Sep 17 00:00:00 2001 From: lemonScaletech Date: Fri, 8 Dec 2023 18:22:24 +0530 Subject: [PATCH 5/9] test: * added integration test for role deletion functionality --- server/test/integration_test.go | 1 + server/test/role_deletion_test.go | 98 +++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 server/test/role_deletion_test.go diff --git a/server/test/integration_test.go b/server/test/integration_test.go index 587235c..2374aaf 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) 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) + }) +} From 7bcd5a70c30c1efbacc388595b6bb862430c8e01 Mon Sep 17 00:00:00 2001 From: lemonScaletech Date: Tue, 2 Jan 2024 11:50:26 +0530 Subject: [PATCH 6/9] feat: * PR suggested changes --- server/resolvers/update_env.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/server/resolvers/update_env.go b/server/resolvers/update_env.go index 68087b4..5b27a11 100644 --- a/server/resolvers/update_env.go +++ b/server/resolvers/update_env.go @@ -328,19 +328,17 @@ func UpdateEnvResolver(ctx context.Context, params model.UpdateEnvInput) (*model } 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(updatedRoles) > 0 { - if 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(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) } } } @@ -359,6 +357,11 @@ func UpdateEnvResolver(ctx context.Context, params model.UpdateEnvInput) (*model 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() From cb01dea902d4f4846b5894b232ef07a98b8fd39e Mon Sep 17 00:00:00 2001 From: scaletech-milan Date: Tue, 2 Jan 2024 12:18:51 +0530 Subject: [PATCH 7/9] Refactor: - Remove redundant mail otp check --- server/resolvers/verify_email.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/server/resolvers/verify_email.go b/server/resolvers/verify_email.go index d3b0e60..3ad399e 100644 --- a/server/resolvers/verify_email.go +++ b/server/resolvers/verify_email.go @@ -71,11 +71,6 @@ func VerifyEmailResolver(ctx context.Context, params model.VerifyEmailInput) (*m log.Debug("totp service not enabled: ", err) } - isMailOTPDisabled, err := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisableMailOTPLogin) - if err != nil || !isMailOTPDisabled { - log.Debug("mail OTP service not enabled: ", err) - } - setOTPMFaSession := func(expiresAt int64) error { mfaSession := uuid.NewString() err = memorystore.Provider.SetMfaSession(user.ID, mfaSession, expiresAt) From 40a0a2fbcc511ea0c69080119cfebe8206a5ec94 Mon Sep 17 00:00:00 2001 From: lemonScaletech Date: Wed, 10 Jan 2024 12:56:30 +0530 Subject: [PATCH 8/9] feat: * added chunks of 1000 for deletion of role --- server/resolvers/update_env.go | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/server/resolvers/update_env.go b/server/resolvers/update_env.go index 5b27a11..62bd33e 100644 --- a/server/resolvers/update_env.go +++ b/server/resolvers/update_env.go @@ -112,17 +112,29 @@ func updateRoles(ctx context.Context, deletedRoles []string) error { return err } - for i := range allData.Users { - roles := utils.DeleteFromArray(allData.Users[i].Roles, deletedRoles) - if len(allData.Users[i].Roles) != len(roles) { - updatedValues := map[string]interface{}{ - "roles": strings.Join(roles, ","), - "updated_at": time.Now().Unix(), - } - id := []string{allData.Users[i].ID} - err = db.Provider.UpdateUsers(ctx, updatedValues, id) - 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 + } } } } From a328121aa38728f466b285f9578ec71a98c2a11f Mon Sep 17 00:00:00 2001 From: cosark <121065588+cosark@users.noreply.github.com> Date: Fri, 12 Jan 2024 02:15:04 -0700 Subject: [PATCH 9/9] Added one-click deployment option to readme table Adding RepoCloud.io as a 1-click deployment options for Authorizer instances. Provides diversity and a user-friendly deployment option. --- README.md | 1 + 1 file changed, 1 insertion(+) 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