Skip to content

Commit 27b51ad

Browse files
authored
Merge pull request #436 from team-scaletech/feat/totp_for_signup
Feat/totp for signup
2 parents 747c82f + cb01dea commit 27b51ad

File tree

6 files changed

+430
-69
lines changed

6 files changed

+430
-69
lines changed

server/resolvers/verify_email.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/google/uuid"
1010
log "github.com/sirupsen/logrus"
1111

12+
"github.com/authorizerdev/authorizer/server/authenticators"
1213
"github.com/authorizerdev/authorizer/server/constants"
1314
"github.com/authorizerdev/authorizer/server/cookie"
1415
"github.com/authorizerdev/authorizer/server/db"
@@ -60,6 +61,66 @@ func VerifyEmailResolver(ctx context.Context, params model.VerifyEmailInput) (*m
6061
return res, err
6162
}
6263

64+
isMFADisabled, err := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisableMultiFactorAuthentication)
65+
if err != nil || !isMFADisabled {
66+
log.Debug("MFA service not enabled: ", err)
67+
}
68+
69+
isTOTPLoginDisabled, err := memorystore.Provider.GetBoolStoreEnvVariable(constants.EnvKeyDisableTOTPLogin)
70+
if err != nil || !isTOTPLoginDisabled {
71+
log.Debug("totp service not enabled: ", err)
72+
}
73+
74+
setOTPMFaSession := func(expiresAt int64) error {
75+
mfaSession := uuid.NewString()
76+
err = memorystore.Provider.SetMfaSession(user.ID, mfaSession, expiresAt)
77+
if err != nil {
78+
log.Debug("Failed to add mfasession: ", err)
79+
return err
80+
}
81+
cookie.SetMfaSession(gc, mfaSession)
82+
return nil
83+
}
84+
85+
// If mfa enabled and also totp enabled
86+
if refs.BoolValue(user.IsMultiFactorAuthEnabled) && !isMFADisabled && !isTOTPLoginDisabled {
87+
expiresAt := time.Now().Add(3 * time.Minute).Unix()
88+
if err := setOTPMFaSession(expiresAt); err != nil {
89+
log.Debug("Failed to set mfa session: ", err)
90+
return nil, err
91+
}
92+
authenticator, err := db.Provider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator)
93+
if err != nil || authenticator == nil || authenticator.VerifiedAt == nil {
94+
// generate totp
95+
// Generate a base64 URL and initiate the registration for TOTP
96+
authConfig, err := authenticators.Provider.Generate(ctx, user.ID)
97+
if err != nil {
98+
log.Debug("error while generating base64 url: ", err)
99+
return nil, err
100+
}
101+
recoveryCodes := []*string{}
102+
for _, code := range authConfig.RecoveryCodes {
103+
recoveryCodes = append(recoveryCodes, refs.NewStringRef(code))
104+
}
105+
// when user is first time registering for totp
106+
res = &model.AuthResponse{
107+
Message: `Proceed to totp verification screen`,
108+
ShouldShowTotpScreen: refs.NewBoolRef(true),
109+
AuthenticatorScannerImage: refs.NewStringRef(authConfig.ScannerImage),
110+
AuthenticatorSecret: refs.NewStringRef(authConfig.Secret),
111+
AuthenticatorRecoveryCodes: recoveryCodes,
112+
}
113+
return res, nil
114+
} else {
115+
//when user is already register for totp
116+
res = &model.AuthResponse{
117+
Message: `Proceed to totp screen`,
118+
ShouldShowTotpScreen: refs.NewBoolRef(true),
119+
}
120+
return res, nil
121+
}
122+
}
123+
63124
isSignUp := false
64125
if user.EmailVerifiedAt == nil {
65126
isSignUp = true

server/test/integration_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ func TestResolvers(t *testing.T) {
130130
mobileSingupTest(t, s)
131131
mobileLoginTests(t, s)
132132
totpLoginTest(t, s)
133+
totpSignupTest(t, s)
133134
forgotPasswordTest(t, s)
134135
forgotPasswordMobileTest(t, s)
135136
resendVerifyEmailTests(t, s)

server/test/signup_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func signupTests(t *testing.T, s TestSetup) {
3737
Password: s.TestInfo.Password,
3838
ConfirmPassword: s.TestInfo.Password,
3939
})
40-
assert.NotNil(t, err, "singup disabled")
40+
assert.NotNil(t, err, "signup disabled")
4141
assert.Nil(t, res)
4242
memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableSignUp, false)
4343
res, err = resolvers.SignupResolver(ctx, model.SignUpInput{

server/test/totp_login_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ func totpLoginTest(t *testing.T, s TestSetup) {
9292
assert.NotNil(t, tf)
9393
code := tf.OTP()
9494
assert.NotEmpty(t, code)
95+
9596
// Set mfa cookie session
9697
mfaSession := uuid.NewString()
9798
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) {
122123
cookie = fmt.Sprintf("%s=%s;", constants.AppCookieName+"_session", sessionToken)
123124
cookie = strings.TrimSuffix(cookie, ";")
124125
req.Header.Set("Cookie", cookie)
126+
125127
//logged out
126128
logout, err := resolvers.LogoutResolver(ctx)
127129
assert.NoError(t, err)

server/test/totp_signup_test.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package test
2+
3+
import (
4+
"bytes"
5+
"encoding/base64"
6+
"fmt"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/authorizerdev/authorizer/server/authenticators"
12+
"github.com/authorizerdev/authorizer/server/constants"
13+
"github.com/authorizerdev/authorizer/server/db"
14+
"github.com/authorizerdev/authorizer/server/graph/model"
15+
"github.com/authorizerdev/authorizer/server/memorystore"
16+
"github.com/authorizerdev/authorizer/server/refs"
17+
"github.com/authorizerdev/authorizer/server/resolvers"
18+
"github.com/authorizerdev/authorizer/server/token"
19+
"github.com/gokyle/twofactor"
20+
"github.com/google/uuid"
21+
"github.com/stretchr/testify/assert"
22+
"github.com/tuotoo/qrcode"
23+
)
24+
25+
func totpSignupTest(t *testing.T, s TestSetup) {
26+
t.Helper()
27+
// Test case to verify TOTP for signup
28+
t.Run(`should verify totp for signup`, func(t *testing.T) {
29+
// Create request and context using test setup
30+
req, ctx := createContext(s)
31+
email := "verify_totp." + s.TestInfo.Email
32+
33+
// Test case: Invalid password (confirm password mismatch)
34+
res, err := resolvers.SignupResolver(ctx, model.SignUpInput{
35+
Email: refs.NewStringRef(email),
36+
Password: s.TestInfo.Password,
37+
ConfirmPassword: s.TestInfo.Password + "s",
38+
})
39+
assert.NotNil(t, err, "invalid password")
40+
assert.Nil(t, res)
41+
42+
{
43+
// Test case: Invalid password ("test" as the password)
44+
res, err = resolvers.SignupResolver(ctx, model.SignUpInput{
45+
Email: refs.NewStringRef(email),
46+
Password: "test",
47+
ConfirmPassword: "test",
48+
})
49+
assert.NotNil(t, err, "invalid password")
50+
assert.Nil(t, res)
51+
}
52+
53+
{
54+
// Test case: Signup disabled
55+
memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableSignUp, true)
56+
res, err = resolvers.SignupResolver(ctx, model.SignUpInput{
57+
Email: refs.NewStringRef(email),
58+
Password: s.TestInfo.Password,
59+
ConfirmPassword: s.TestInfo.Password,
60+
})
61+
assert.NotNil(t, err, "signup disabled")
62+
assert.Nil(t, res)
63+
}
64+
65+
{
66+
// Test case: Successful signup
67+
memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableSignUp, false)
68+
res, err = resolvers.SignupResolver(ctx, model.SignUpInput{
69+
Email: refs.NewStringRef(email),
70+
Password: s.TestInfo.Password,
71+
ConfirmPassword: s.TestInfo.Password,
72+
AppData: map[string]interface{}{
73+
"test": "test",
74+
},
75+
})
76+
assert.Nil(t, err, "signup should be successful")
77+
user := *res.User
78+
assert.Equal(t, email, refs.StringValue(user.Email))
79+
assert.Equal(t, "test", user.AppData["test"])
80+
assert.Nil(t, res.AccessToken, "access token should be nil")
81+
}
82+
83+
{
84+
// Test case: Duplicate email (should throw an error)
85+
res, err = resolvers.SignupResolver(ctx, model.SignUpInput{
86+
Email: refs.NewStringRef(email),
87+
Password: s.TestInfo.Password,
88+
ConfirmPassword: s.TestInfo.Password,
89+
})
90+
assert.NotNil(t, err, "should throw duplicate email error")
91+
assert.Nil(t, res)
92+
}
93+
94+
// Clean up data for the email
95+
cleanData(email)
96+
97+
{
98+
// Test case: Email verification and TOTP setup
99+
memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableEmailVerification, false)
100+
101+
// Sign up a user
102+
res, err := resolvers.SignupResolver(ctx, model.SignUpInput{
103+
Email: refs.NewStringRef(email),
104+
Password: s.TestInfo.Password,
105+
ConfirmPassword: s.TestInfo.Password,
106+
})
107+
assert.Nil(t, err, "Expected no error but got: %v", err)
108+
assert.Equal(t, "Verification email has been sent. Please check your inbox", res.Message)
109+
110+
// Retrieve user and update for TOTP setup
111+
user, err := db.Provider.GetUserByID(ctx, res.User.ID)
112+
assert.Nil(t, err, "Expected no error but got: %v", err)
113+
assert.NotNil(t, user)
114+
115+
// Enable multi-factor authentication and update the user
116+
memorystore.Provider.UpdateEnvVariable(constants.EnvKeyDisableTOTPLogin, false)
117+
user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true)
118+
updatedUser, err := db.Provider.UpdateUser(ctx, user)
119+
assert.Nil(t, err, "Expected no error but got: %v", err)
120+
assert.Equal(t, true, *updatedUser.IsMultiFactorAuthEnabled)
121+
122+
// Initialise totp authenticator store
123+
authenticators.InitTOTPStore()
124+
125+
// Verify an email and get TOTP response
126+
verificationRequest, err := db.Provider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeBasicAuthSignup)
127+
assert.Nil(t, err)
128+
assert.Equal(t, email, verificationRequest.Email)
129+
verifyRes, err := resolvers.VerifyEmailResolver(ctx, model.VerifyEmailInput{
130+
Token: verificationRequest.Token,
131+
})
132+
assert.Nil(t, err, "Expected no error but got: %v", err)
133+
assert.NotNil(t, &verifyRes)
134+
assert.Nil(t, verifyRes.AccessToken)
135+
assert.Equal(t, "Proceed to totp verification screen", verifyRes.Message)
136+
assert.NotEqual(t, *verifyRes.AuthenticatorScannerImage, "", "totp url should not be empty")
137+
assert.NotEqual(t, *verifyRes.AuthenticatorSecret, "", "totp secret should not be empty")
138+
assert.NotNil(t, verifyRes.AuthenticatorRecoveryCodes)
139+
140+
// Get TOTP URL for for validation
141+
pngBytes, err := base64.StdEncoding.DecodeString(*verifyRes.AuthenticatorScannerImage)
142+
assert.NoError(t, err)
143+
qrmatrix, err := qrcode.Decode(bytes.NewReader(pngBytes))
144+
assert.NoError(t, err)
145+
tf, label, err := twofactor.FromURL(qrmatrix.Content)
146+
data := strings.Split(label, ":")
147+
assert.NoError(t, err)
148+
assert.Equal(t, email, data[1])
149+
assert.NotNil(t, tf)
150+
code := tf.OTP()
151+
assert.NotEmpty(t, code)
152+
153+
// Set MFA cookie session
154+
mfaSession := uuid.NewString()
155+
memorystore.Provider.SetMfaSession(res.User.ID, mfaSession, time.Now().Add(1*time.Minute).Unix())
156+
cookie := fmt.Sprintf("%s=%s;", constants.MfaCookieName+"_session", mfaSession)
157+
cookie = strings.TrimSuffix(cookie, ";")
158+
req.Header.Set("Cookie", cookie)
159+
valid, err := resolvers.VerifyOtpResolver(ctx, model.VerifyOTPRequest{
160+
Email: &email,
161+
IsTotp: refs.NewBoolRef(true),
162+
Otp: code,
163+
})
164+
accessToken := *valid.AccessToken
165+
assert.NoError(t, err)
166+
assert.NotNil(t, accessToken)
167+
assert.NotEmpty(t, valid.Message)
168+
assert.NotEmpty(t, accessToken)
169+
claims, err := token.ParseJWTToken(accessToken)
170+
assert.NoError(t, err)
171+
assert.NotEmpty(t, claims)
172+
signUpMethod := claims["login_method"]
173+
sessionKey := res.User.ID
174+
if signUpMethod != nil && signUpMethod != "" {
175+
sessionKey = signUpMethod.(string) + ":" + res.User.ID
176+
}
177+
sessionToken, err := memorystore.Provider.GetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+claims["nonce"].(string))
178+
assert.NoError(t, err)
179+
assert.NotEmpty(t, sessionToken)
180+
cookie = fmt.Sprintf("%s=%s;", constants.AppCookieName+"_session", sessionToken)
181+
cookie = strings.TrimSuffix(cookie, ";")
182+
req.Header.Set("Cookie", cookie)
183+
}
184+
// Clean up data for the email
185+
cleanData(email)
186+
})
187+
}

0 commit comments

Comments
 (0)