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 |
| [docs](https://docs.authorizer.dev/deployment/heroku) |
| Render | [](https://render.com/deploy?repo=https://github.com/authorizerdev/authorizer-render) | [docs](https://docs.authorizer.dev/deployment/render) |
| Koyeb |
| [docs](https://docs.authorizer.dev/deployment/koyeb) |
+| 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
+}