1012 lines
35 KiB
Go
1012 lines
35 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gofrs/uuid"
|
|
|
|
"github.com/pquerna/otp"
|
|
"github.com/supabase/auth/internal/api/sms_provider"
|
|
"github.com/supabase/auth/internal/conf"
|
|
"github.com/supabase/auth/internal/crypto"
|
|
"github.com/supabase/auth/internal/models"
|
|
"github.com/supabase/auth/internal/utilities"
|
|
|
|
"github.com/pquerna/otp/totp"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
|
|
type MFATestSuite struct {
|
|
suite.Suite
|
|
API *API
|
|
Config *conf.GlobalConfiguration
|
|
TestDomain string
|
|
TestEmail string
|
|
TestOTPKey *otp.Key
|
|
TestPassword string
|
|
TestUser *models.User
|
|
TestSession *models.Session
|
|
TestSecondarySession *models.Session
|
|
}
|
|
|
|
func TestMFA(t *testing.T) {
|
|
api, config, err := setupAPIForTest()
|
|
require.NoError(t, err)
|
|
ts := &MFATestSuite{
|
|
API: api,
|
|
Config: config,
|
|
}
|
|
defer api.db.Close()
|
|
suite.Run(t, ts)
|
|
}
|
|
|
|
func (ts *MFATestSuite) SetupTest() {
|
|
models.TruncateAll(ts.API.db)
|
|
|
|
ts.TestEmail = "test@example.com"
|
|
ts.TestPassword = "password"
|
|
// Create user
|
|
u, err := models.NewUser("123456789", ts.TestEmail, ts.TestPassword, ts.Config.JWT.Aud, nil)
|
|
require.NoError(ts.T(), err, "Error creating test user model")
|
|
require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
|
|
// Create Factor
|
|
f := models.NewTOTPFactor(u, "test_factor")
|
|
require.NoError(ts.T(), f.SetSecret("secretkey", ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey))
|
|
require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor")
|
|
// Create corresponding session
|
|
s, err := models.NewSession(u.ID, &f.ID)
|
|
require.NoError(ts.T(), err, "Error creating test session")
|
|
require.NoError(ts.T(), ts.API.db.Create(s), "Error saving test session")
|
|
|
|
u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.TestEmail, ts.Config.JWT.Aud)
|
|
ts.Require().NoError(err)
|
|
|
|
ts.TestUser = u
|
|
ts.TestSession = s
|
|
|
|
secondarySession, err := models.NewSession(ts.TestUser.ID, &f.ID)
|
|
require.NoError(ts.T(), err, "Error creating test session")
|
|
require.NoError(ts.T(), ts.API.db.Create(secondarySession), "Error saving test session")
|
|
|
|
ts.TestSecondarySession = secondarySession
|
|
|
|
// Generate TOTP related settings
|
|
testDomain := strings.Split(ts.TestEmail, "@")[1]
|
|
ts.TestDomain = testDomain
|
|
|
|
// By default MFA Phone is disabled
|
|
ts.Config.MFA.Phone.EnrollEnabled = true
|
|
ts.Config.MFA.Phone.VerifyEnabled = true
|
|
|
|
ts.Config.MFA.WebAuthn.EnrollEnabled = true
|
|
ts.Config.MFA.WebAuthn.VerifyEnabled = true
|
|
|
|
key, err := totp.Generate(totp.GenerateOpts{
|
|
Issuer: ts.TestDomain,
|
|
AccountName: ts.TestEmail,
|
|
})
|
|
require.NoError(ts.T(), err)
|
|
ts.TestOTPKey = key
|
|
|
|
}
|
|
|
|
func (ts *MFATestSuite) generateAAL1Token(user *models.User, sessionId *uuid.UUID) string {
|
|
// Not an actual path. Dummy request to simulate a signup request that we can use in generateAccessToken
|
|
req := httptest.NewRequest(http.MethodPost, "/factors", nil)
|
|
token, _, err := ts.API.generateAccessToken(req, ts.API.db, user, sessionId, models.TOTPSignIn)
|
|
require.NoError(ts.T(), err, "Error generating access token")
|
|
return token
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestEnrollFactor() {
|
|
testFriendlyName := "bob"
|
|
alternativeFriendlyName := "john"
|
|
|
|
token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
|
|
|
|
var cases = []struct {
|
|
desc string
|
|
friendlyName string
|
|
factorType string
|
|
issuer string
|
|
phone string
|
|
expectedCode int
|
|
}{
|
|
{
|
|
desc: "TOTP: No issuer",
|
|
friendlyName: alternativeFriendlyName,
|
|
factorType: models.TOTP,
|
|
issuer: "",
|
|
phone: "",
|
|
expectedCode: http.StatusOK,
|
|
},
|
|
{
|
|
desc: "Invalid factor type",
|
|
friendlyName: testFriendlyName,
|
|
factorType: "invalid_factor",
|
|
issuer: ts.TestDomain,
|
|
phone: "",
|
|
expectedCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
desc: "TOTP: Factor has friendly name",
|
|
friendlyName: testFriendlyName,
|
|
factorType: models.TOTP,
|
|
issuer: ts.TestDomain,
|
|
phone: "",
|
|
expectedCode: http.StatusOK,
|
|
},
|
|
{
|
|
desc: "TOTP: Enrolling without friendly name",
|
|
friendlyName: "",
|
|
factorType: models.TOTP,
|
|
issuer: ts.TestDomain,
|
|
phone: "",
|
|
expectedCode: http.StatusOK,
|
|
},
|
|
{
|
|
desc: "Phone: Enroll with friendly name",
|
|
friendlyName: "phone_factor",
|
|
factorType: models.Phone,
|
|
phone: "+12345677889",
|
|
expectedCode: http.StatusOK,
|
|
},
|
|
{
|
|
desc: "Phone: Enroll with invalid phone number",
|
|
friendlyName: "phone_factor",
|
|
factorType: models.Phone,
|
|
phone: "+1",
|
|
expectedCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
desc: "Phone: Enroll without phone number should return error",
|
|
friendlyName: "phone_factor_fail",
|
|
factorType: models.Phone,
|
|
phone: "",
|
|
expectedCode: http.StatusBadRequest,
|
|
},
|
|
{
|
|
desc: "WebAuthn: Enroll with friendly name",
|
|
friendlyName: "webauthn_factor",
|
|
factorType: models.WebAuthn,
|
|
expectedCode: http.StatusOK,
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
ts.Run(c.desc, func() {
|
|
w := performEnrollFlow(ts, token, c.friendlyName, c.factorType, c.issuer, c.phone, c.expectedCode)
|
|
enrollResp := EnrollFactorResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
|
|
|
|
if c.expectedCode == http.StatusOK {
|
|
addedFactor, err := models.FindFactorByFactorID(ts.API.db, enrollResp.ID)
|
|
require.NoError(ts.T(), err)
|
|
require.False(ts.T(), addedFactor.IsVerified())
|
|
|
|
if c.friendlyName != "" {
|
|
require.Equal(ts.T(), c.friendlyName, addedFactor.FriendlyName)
|
|
}
|
|
|
|
if c.factorType == models.TOTP {
|
|
qrCode := enrollResp.TOTP.QRCode
|
|
hasSVGStartAndEnd := strings.Contains(qrCode, "<svg") && strings.Contains(qrCode, "</svg>")
|
|
require.True(ts.T(), hasSVGStartAndEnd)
|
|
require.Equal(ts.T(), c.friendlyName, enrollResp.FriendlyName)
|
|
}
|
|
}
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestDuplicateEnrollPhoneFactor() {
|
|
testPhoneNumber := "+12345677889"
|
|
altPhoneNumber := "+987412444444"
|
|
friendlyName := "phone_factor"
|
|
altFriendlyName := "alt_phone_factor"
|
|
token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
|
|
|
|
var cases = []struct {
|
|
desc string
|
|
earlierFactorName string
|
|
laterFactorName string
|
|
phone string
|
|
secondPhone string
|
|
expectedCode int
|
|
expectedNumberOfFactors int
|
|
}{
|
|
{
|
|
desc: "Phone: Only the latest factor should persist when enrolling two unverified phone factors with the same number",
|
|
earlierFactorName: friendlyName,
|
|
laterFactorName: altFriendlyName,
|
|
phone: testPhoneNumber,
|
|
secondPhone: testPhoneNumber,
|
|
expectedNumberOfFactors: 1,
|
|
},
|
|
|
|
{
|
|
desc: "Phone: Both factors should persist when enrolling two different unverified numbers",
|
|
earlierFactorName: friendlyName,
|
|
laterFactorName: altFriendlyName,
|
|
phone: testPhoneNumber,
|
|
secondPhone: altPhoneNumber,
|
|
expectedNumberOfFactors: 2,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
ts.Run(c.desc, func() {
|
|
// Delete all test factors to start from clean slate
|
|
require.NoError(ts.T(), ts.API.db.Destroy(ts.TestUser.Factors))
|
|
_ = performEnrollFlow(ts, token, c.earlierFactorName, models.Phone, ts.TestDomain, c.phone, http.StatusOK)
|
|
|
|
w := performEnrollFlow(ts, token, c.laterFactorName, models.Phone, ts.TestDomain, c.secondPhone, http.StatusOK)
|
|
enrollResp := EnrollFactorResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
|
|
|
|
laterFactor, err := models.FindFactorByFactorID(ts.API.db, enrollResp.ID)
|
|
require.NoError(ts.T(), err)
|
|
require.False(ts.T(), laterFactor.IsVerified())
|
|
|
|
require.NoError(ts.T(), ts.API.db.Eager("Factors").Find(ts.TestUser, ts.TestUser.ID))
|
|
require.Equal(ts.T(), len(ts.TestUser.Factors), c.expectedNumberOfFactors)
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestDuplicateEnrollPhoneFactorWithVerified() {
|
|
testPhoneNumber := "+12345677889"
|
|
friendlyName := "phone_factor"
|
|
altFriendlyName := "alt_phone_factor"
|
|
token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
|
|
|
|
ts.Run("Phone: Enrolling a factor with the same number as an existing verified phone factor should result in an error", func() {
|
|
require.NoError(ts.T(), ts.API.db.Destroy(ts.TestUser.Factors))
|
|
|
|
// Setup verified factor
|
|
w := performEnrollFlow(ts, token, friendlyName, models.Phone, ts.TestDomain, testPhoneNumber, http.StatusOK)
|
|
enrollResp := EnrollFactorResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
|
|
firstFactor, err := models.FindFactorByFactorID(ts.API.db, enrollResp.ID)
|
|
require.NoError(ts.T(), err)
|
|
require.NoError(ts.T(), firstFactor.UpdateStatus(ts.API.db, models.FactorStateVerified))
|
|
|
|
expectedStatusCode := http.StatusUnprocessableEntity
|
|
_ = performEnrollFlow(ts, token, altFriendlyName, models.Phone, ts.TestDomain, testPhoneNumber, expectedStatusCode)
|
|
|
|
require.NoError(ts.T(), ts.API.db.Eager("Factors").Find(ts.TestUser, ts.TestUser.ID))
|
|
require.Equal(ts.T(), len(ts.TestUser.Factors), 1)
|
|
})
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestDuplicateTOTPEnrollsReturnExpectedMessage() {
|
|
friendlyName := "mary"
|
|
issuer := "https://issuer.com"
|
|
token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
|
|
_ = performEnrollFlow(ts, token, friendlyName, models.TOTP, issuer, "", http.StatusOK)
|
|
response := performEnrollFlow(ts, token, friendlyName, models.TOTP, issuer, "", http.StatusUnprocessableEntity)
|
|
|
|
var errorResponse HTTPError
|
|
err := json.NewDecoder(response.Body).Decode(&errorResponse)
|
|
require.NoError(ts.T(), err)
|
|
|
|
require.Contains(ts.T(), errorResponse.ErrorCode, ErrorCodeMFAFactorNameConflict)
|
|
}
|
|
|
|
func (ts *MFATestSuite) AAL2RequiredToUpdatePasswordAfterEnrollment() {
|
|
resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */)
|
|
accessTokenResp := &AccessTokenResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))
|
|
|
|
var w *httptest.ResponseRecorder
|
|
var buffer bytes.Buffer
|
|
token := accessTokenResp.Token
|
|
// Update Password to new password
|
|
newPassword := "newpass"
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"password": newPassword,
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
|
|
w = httptest.NewRecorder()
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
|
|
|
// Logout
|
|
reqURL := "http://localhost/logout"
|
|
req = httptest.NewRequest(http.MethodPost, reqURL, nil)
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
w = httptest.NewRecorder()
|
|
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
require.Equal(ts.T(), http.StatusNoContent, w.Code)
|
|
|
|
// Get AAL1 token
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"email": ts.TestEmail,
|
|
"password": newPassword,
|
|
}))
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w = httptest.NewRecorder()
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
|
session1 := AccessTokenResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&session1))
|
|
|
|
// Update Password again, this should fail
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"password": ts.TestPassword,
|
|
}))
|
|
|
|
req = httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session1.Token))
|
|
|
|
w = httptest.NewRecorder()
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
require.Equal(ts.T(), http.StatusUnauthorized, w.Code)
|
|
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestMultipleEnrollsCleanupExpiredFactors() {
|
|
// All factors are deleted when a subsequent enroll is made
|
|
ts.API.config.MFA.FactorExpiryDuration = 0 * time.Second
|
|
// Verified factor should not be deleted (Factor 1)
|
|
resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */)
|
|
numFactors := 5
|
|
accessTokenResp := &AccessTokenResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))
|
|
|
|
var w *httptest.ResponseRecorder
|
|
token := accessTokenResp.Token
|
|
for i := 0; i < numFactors; i++ {
|
|
w = performEnrollFlow(ts, token, "first-name", models.TOTP, "https://issuer.com", "", http.StatusOK)
|
|
}
|
|
|
|
enrollResp := EnrollFactorResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
|
|
|
|
// Make a challenge so last, unverified factor isn't deleted on next enroll (Factor 2)
|
|
_ = performChallengeFlow(ts, enrollResp.ID, token)
|
|
|
|
// Enroll another Factor (Factor 3)
|
|
_ = performEnrollFlow(ts, token, "second-name", models.TOTP, "https://issuer.com", "", http.StatusOK)
|
|
require.NoError(ts.T(), ts.API.db.Eager("Factors").Find(ts.TestUser, ts.TestUser.ID))
|
|
require.Equal(ts.T(), 3, len(ts.TestUser.Factors))
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestChallengeTOTPFactor() {
|
|
// Test Factor is a TOTP Factor
|
|
f := ts.TestUser.Factors[0]
|
|
token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
|
|
w := performChallengeFlow(ts, f.ID, token)
|
|
challengeResp := ChallengeFactorResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&challengeResp))
|
|
|
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
|
require.Equal(ts.T(), challengeResp.Type, models.TOTP)
|
|
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestChallengeSMSFactor() {
|
|
// Challenge should still work with phone provider disabled
|
|
ts.Config.External.Phone.Enabled = false
|
|
ts.Config.Hook.SendSMS.Enabled = true
|
|
ts.Config.Hook.SendSMS.URI = "pg-functions://postgres/auth/send_sms_mfa_mock"
|
|
|
|
ts.Config.MFA.Phone.MaxFrequency = 0 * time.Second
|
|
|
|
require.NoError(ts.T(), ts.Config.Hook.SendSMS.PopulateExtensibilityPoint())
|
|
require.NoError(ts.T(), ts.API.db.RawQuery(`
|
|
create or replace function send_sms_mfa_mock(input jsonb)
|
|
returns json as $$
|
|
begin
|
|
return input;
|
|
end; $$ language plpgsql;`).Exec())
|
|
|
|
phone := "+1234567"
|
|
friendlyName := "testchallengesmsfactor"
|
|
|
|
f := models.NewPhoneFactor(ts.TestUser, phone, friendlyName)
|
|
require.NoError(ts.T(), ts.API.db.Create(f), "Error creating new SMS factor")
|
|
token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
|
|
|
|
var cases = []struct {
|
|
desc string
|
|
channel string
|
|
expectedCode int
|
|
}{
|
|
{
|
|
desc: "SMS Channel",
|
|
channel: sms_provider.SMSProvider,
|
|
expectedCode: http.StatusOK,
|
|
},
|
|
{
|
|
desc: "WhatsApp Channel",
|
|
channel: sms_provider.WhatsappProvider,
|
|
expectedCode: http.StatusOK,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
ts.Run(tc.desc, func() {
|
|
w := performSMSChallengeFlow(ts, f.ID, token, tc.channel)
|
|
challengeResp := ChallengeFactorResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&challengeResp))
|
|
require.Equal(ts.T(), challengeResp.Type, models.Phone)
|
|
require.Equal(ts.T(), tc.expectedCode, w.Code, tc.desc)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestMFAVerifyFactor() {
|
|
cases := []struct {
|
|
desc string
|
|
validChallenge bool
|
|
validCode bool
|
|
factorType string
|
|
expectedHTTPCode int
|
|
}{
|
|
{
|
|
desc: "Invalid: Valid code and expired challenge",
|
|
validChallenge: false,
|
|
validCode: true,
|
|
factorType: models.TOTP,
|
|
expectedHTTPCode: http.StatusUnprocessableEntity,
|
|
},
|
|
{
|
|
desc: "Invalid: Invalid code and valid challenge",
|
|
validChallenge: true,
|
|
validCode: false,
|
|
factorType: models.TOTP,
|
|
expectedHTTPCode: http.StatusUnprocessableEntity,
|
|
},
|
|
{
|
|
desc: "Valid /verify request",
|
|
validChallenge: true,
|
|
validCode: true,
|
|
factorType: models.TOTP,
|
|
expectedHTTPCode: http.StatusOK,
|
|
},
|
|
{
|
|
desc: "Invalid: Valid code and expired challenge (SMS)",
|
|
validChallenge: false,
|
|
validCode: true,
|
|
factorType: models.Phone,
|
|
expectedHTTPCode: http.StatusUnprocessableEntity,
|
|
},
|
|
{
|
|
desc: "Invalid: Invalid code and valid challenge (SMS)",
|
|
validChallenge: true,
|
|
validCode: false,
|
|
factorType: models.Phone,
|
|
expectedHTTPCode: http.StatusUnprocessableEntity,
|
|
},
|
|
{
|
|
desc: "Valid /verify request (SMS)",
|
|
validChallenge: true,
|
|
validCode: true,
|
|
factorType: models.Phone,
|
|
expectedHTTPCode: http.StatusOK,
|
|
},
|
|
}
|
|
for _, v := range cases {
|
|
ts.Run(v.desc, func() {
|
|
// Authenticate users and set secret
|
|
var buffer bytes.Buffer
|
|
r, err := models.GrantAuthenticatedUser(ts.API.db, ts.TestUser, models.GrantParams{})
|
|
require.NoError(ts.T(), err)
|
|
token := ts.generateAAL1Token(ts.TestUser, r.SessionId)
|
|
var f *models.Factor
|
|
var sharedSecret string
|
|
|
|
if v.factorType == models.TOTP {
|
|
friendlyName := uuid.Must(uuid.NewV4()).String()
|
|
f = models.NewTOTPFactor(ts.TestUser, friendlyName)
|
|
sharedSecret = ts.TestOTPKey.Secret()
|
|
f.Secret = sharedSecret
|
|
require.NoError(ts.T(), ts.API.db.Create(f), "Error updating new test factor")
|
|
} else if v.factorType == models.Phone {
|
|
friendlyName := uuid.Must(uuid.NewV4()).String()
|
|
numDigits := 10
|
|
otp := crypto.GenerateOtp(numDigits)
|
|
require.NoError(ts.T(), err)
|
|
phone := fmt.Sprintf("+%s", otp)
|
|
f = models.NewPhoneFactor(ts.TestUser, phone, friendlyName)
|
|
require.NoError(ts.T(), ts.API.db.Create(f), "Error creating new SMS factor")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/factors/%s/verify", f.ID), &buffer)
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
|
|
var c *models.Challenge
|
|
var code string
|
|
if v.factorType == models.TOTP {
|
|
c = f.CreateChallenge(utilities.GetIPAddress(req))
|
|
// Verify TOTP code
|
|
code, err = totp.GenerateCode(sharedSecret, time.Now().UTC())
|
|
require.NoError(ts.T(), err)
|
|
} else if v.factorType == models.Phone {
|
|
code = "123456"
|
|
c, err = f.CreatePhoneChallenge(utilities.GetIPAddress(req), code, ts.Config.Security.DBEncryption.Encrypt, ts.Config.Security.DBEncryption.EncryptionKeyID, ts.Config.Security.DBEncryption.EncryptionKey)
|
|
require.NoError(ts.T(), err)
|
|
}
|
|
|
|
if !v.validCode && v.factorType == models.TOTP {
|
|
code, err = totp.GenerateCode(sharedSecret, time.Now().UTC().Add(-1*time.Minute*time.Duration(1)))
|
|
require.NoError(ts.T(), err)
|
|
|
|
} else if !v.validCode && v.factorType == models.Phone {
|
|
invalidSuffix := "1"
|
|
code += invalidSuffix
|
|
}
|
|
|
|
require.NoError(ts.T(), ts.API.db.Create(c), "Error saving new test challenge")
|
|
if !v.validChallenge {
|
|
// Set challenge creation so that it has expired in present time.
|
|
newCreatedAt := time.Now().UTC().Add(-1 * time.Second * time.Duration(ts.Config.MFA.ChallengeExpiryDuration+1))
|
|
// created_at is managed by buffalo(ORM) needs to be raw query to be updated
|
|
err := ts.API.db.RawQuery("UPDATE auth.mfa_challenges SET created_at = ? WHERE factor_id = ?", newCreatedAt, f.ID).Exec()
|
|
require.NoError(ts.T(), err, "Error updating new test challenge")
|
|
}
|
|
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"challenge_id": c.ID,
|
|
"code": code,
|
|
}))
|
|
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
require.Equal(ts.T(), v.expectedHTTPCode, w.Code)
|
|
|
|
if v.expectedHTTPCode == http.StatusOK {
|
|
// Ensure alternate session has been deleted
|
|
_, err = models.FindSessionByID(ts.API.db, ts.TestSecondarySession.ID, false)
|
|
require.EqualError(ts.T(), err, models.SessionNotFoundError{}.Error())
|
|
}
|
|
if !v.validChallenge {
|
|
// Ensure invalid challenges are deleted
|
|
_, err := f.FindChallengeByID(ts.API.db, c.ID)
|
|
require.EqualError(ts.T(), err, models.ChallengeNotFoundError{}.Error())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
|
|
cases := []struct {
|
|
desc string
|
|
isAAL2 bool
|
|
expectedHTTPCode int
|
|
}{
|
|
{
|
|
desc: "Verified Factor: AAL1",
|
|
isAAL2: false,
|
|
expectedHTTPCode: http.StatusUnprocessableEntity,
|
|
},
|
|
{
|
|
desc: "Verified Factor: AAL2, Success",
|
|
isAAL2: true,
|
|
expectedHTTPCode: http.StatusOK,
|
|
},
|
|
}
|
|
for _, v := range cases {
|
|
ts.Run(v.desc, func() {
|
|
var buffer bytes.Buffer
|
|
|
|
// Create Session to test behaviour which downgrades other sessions
|
|
f := ts.TestUser.Factors[0]
|
|
require.NoError(ts.T(), f.UpdateStatus(ts.API.db, models.FactorStateVerified))
|
|
if v.isAAL2 {
|
|
ts.TestSession.UpdateAALAndAssociatedFactor(ts.API.db, models.AAL2, &f.ID)
|
|
}
|
|
token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
|
|
w := ServeAuthenticatedRequest(ts, http.MethodDelete, fmt.Sprintf("/factors/%s", f.ID), token, buffer)
|
|
require.Equal(ts.T(), v.expectedHTTPCode, w.Code)
|
|
|
|
if v.expectedHTTPCode == http.StatusOK {
|
|
_, err := models.FindFactorByFactorID(ts.API.db, f.ID)
|
|
require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
|
|
session, _ := models.FindSessionByID(ts.API.db, ts.TestSecondarySession.ID, false)
|
|
require.Equal(ts.T(), models.AAL1.String(), session.GetAAL())
|
|
require.Nil(ts.T(), session.FactorID)
|
|
|
|
}
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
|
|
var buffer bytes.Buffer
|
|
f := ts.TestUser.Factors[0]
|
|
|
|
token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"factor_id": f.ID,
|
|
}))
|
|
|
|
w := ServeAuthenticatedRequest(ts, http.MethodDelete, fmt.Sprintf("/factors/%s", f.ID), token, buffer)
|
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
|
|
|
_, err := models.FindFactorByFactorID(ts.API.db, f.ID)
|
|
require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
|
|
session, _ := models.FindSessionByID(ts.API.db, ts.TestSecondarySession.ID, false)
|
|
require.Equal(ts.T(), models.AAL1.String(), session.GetAAL())
|
|
require.Nil(ts.T(), session.FactorID)
|
|
|
|
}
|
|
|
|
// Integration Tests
|
|
func (ts *MFATestSuite) TestSessionsMaintainAALOnRefresh() {
|
|
ts.Config.Security.RefreshTokenRotationEnabled = true
|
|
resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */)
|
|
accessTokenResp := &AccessTokenResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))
|
|
|
|
var buffer bytes.Buffer
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"refresh_token": accessTokenResp.RefreshToken,
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
|
|
|
data := &AccessTokenResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
|
|
|
ctx, err := ts.API.parseJWTClaims(data.Token, req)
|
|
require.NoError(ts.T(), err)
|
|
ctx, err = ts.API.maybeLoadUserOrSession(ctx)
|
|
require.NoError(ts.T(), err)
|
|
require.True(ts.T(), getSession(ctx).IsAAL2())
|
|
}
|
|
|
|
// Performing MFA Verification followed by a sign in should return an AAL1 session and an AAL2 session
|
|
func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
|
|
ts.Config.Security.RefreshTokenRotationEnabled = true
|
|
resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */)
|
|
accessTokenResp := &AccessTokenResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))
|
|
|
|
var buffer bytes.Buffer
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"email": ts.TestEmail,
|
|
"password": ts.TestPassword,
|
|
}))
|
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
|
|
|
data := &AccessTokenResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
|
ctx, err := ts.API.parseJWTClaims(data.Token, req)
|
|
require.NoError(ts.T(), err)
|
|
|
|
ctx, err = ts.API.maybeLoadUserOrSession(ctx)
|
|
require.NoError(ts.T(), err)
|
|
|
|
require.Equal(ts.T(), models.AAL1.String(), getSession(ctx).GetAAL())
|
|
session, err := models.FindSessionByUserID(ts.API.db, accessTokenResp.User.ID)
|
|
require.NoError(ts.T(), err)
|
|
require.True(ts.T(), session.IsAAL2())
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestChallengeWebAuthnFactor() {
|
|
factor := models.NewWebAuthnFactor(ts.TestUser, "WebAuthnfactor")
|
|
validWebAuthnConfiguration := &WebAuthnParams{
|
|
RPID: "localhost",
|
|
RPOrigins: "http://localhost:3000",
|
|
}
|
|
require.NoError(ts.T(), ts.API.db.Create(factor), "Error saving new test factor")
|
|
token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
|
|
w := performChallengeWebAuthnFlow(ts, factor.ID, token, validWebAuthnConfiguration)
|
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
|
}
|
|
|
|
func performChallengeWebAuthnFlow(ts *MFATestSuite, factorID uuid.UUID, token string, webauthn *WebAuthnParams) *httptest.ResponseRecorder {
|
|
var buffer bytes.Buffer
|
|
err := json.NewEncoder(&buffer).Encode(ChallengeFactorParams{WebAuthn: webauthn})
|
|
require.NoError(ts.T(), err)
|
|
w := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), token, buffer)
|
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
|
return w
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestChallengeFactorNotOwnedByUser() {
|
|
var buffer bytes.Buffer
|
|
email := "nomfaenabled@test.com"
|
|
password := "testpassword"
|
|
signUpResp := signUp(ts, email, password)
|
|
|
|
friendlyName := "testfactor"
|
|
phoneNumber := "+1234567"
|
|
|
|
otherUsersPhoneFactor := models.NewPhoneFactor(ts.TestUser, phoneNumber, friendlyName)
|
|
require.NoError(ts.T(), ts.API.db.Create(otherUsersPhoneFactor), "Error creating factor")
|
|
|
|
w := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", otherUsersPhoneFactor.ID), signUpResp.Token, buffer)
|
|
|
|
expectedError := notFoundError(ErrorCodeMFAFactorNotFound, "Factor not found")
|
|
|
|
var data HTTPError
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
|
|
|
require.Equal(ts.T(), expectedError.ErrorCode, data.ErrorCode)
|
|
require.Equal(ts.T(), http.StatusNotFound, w.Code)
|
|
|
|
}
|
|
|
|
func signUp(ts *MFATestSuite, email, password string) (signUpResp AccessTokenResponse) {
|
|
ts.API.config.Mailer.Autoconfirm = true
|
|
var buffer bytes.Buffer
|
|
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"email": email,
|
|
"password": password,
|
|
}))
|
|
|
|
// Setup request
|
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/signup", &buffer)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
|
data := AccessTokenResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
|
|
return data
|
|
}
|
|
|
|
func performTestSignupAndVerify(ts *MFATestSuite, email, password string, requireStatusOK bool) *httptest.ResponseRecorder {
|
|
signUpResp := signUp(ts, email, password)
|
|
resp := performEnrollAndVerify(ts, signUpResp.Token, requireStatusOK)
|
|
|
|
return resp
|
|
|
|
}
|
|
|
|
func performEnrollFlow(ts *MFATestSuite, token, friendlyName, factorType, issuer string, phone string, expectedCode int) *httptest.ResponseRecorder {
|
|
var buffer bytes.Buffer
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(EnrollFactorParams{FriendlyName: friendlyName, FactorType: factorType, Issuer: issuer, Phone: phone}))
|
|
w := ServeAuthenticatedRequest(ts, http.MethodPost, "http://localhost/factors/", token, buffer)
|
|
require.Equal(ts.T(), expectedCode, w.Code)
|
|
return w
|
|
}
|
|
|
|
func ServeAuthenticatedRequest(ts *MFATestSuite, method, path, token string, buffer bytes.Buffer) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(method, path, &buffer)
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
ts.API.handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func performVerifyFlow(ts *MFATestSuite, challengeID, factorID uuid.UUID, token string, requireStatusOK bool) *httptest.ResponseRecorder {
|
|
var buffer bytes.Buffer
|
|
|
|
factor, err := models.FindFactorByFactorID(ts.API.db, factorID)
|
|
require.NoError(ts.T(), err)
|
|
require.NotNil(ts.T(), factor)
|
|
|
|
totpSecret := factor.Secret
|
|
|
|
if es := crypto.ParseEncryptedString(factor.Secret); es != nil {
|
|
secret, err := es.Decrypt(factor.ID.String(), ts.API.config.Security.DBEncryption.DecryptionKeys)
|
|
require.NoError(ts.T(), err)
|
|
require.NotNil(ts.T(), secret)
|
|
|
|
totpSecret = string(secret)
|
|
}
|
|
|
|
code, err := totp.GenerateCode(totpSecret, time.Now().UTC())
|
|
require.NoError(ts.T(), err)
|
|
|
|
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
|
|
"challenge_id": challengeID,
|
|
"code": code,
|
|
}))
|
|
|
|
y := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("/factors/%s/verify", factorID), token, buffer)
|
|
|
|
if requireStatusOK {
|
|
require.Equal(ts.T(), http.StatusOK, y.Code)
|
|
}
|
|
return y
|
|
}
|
|
|
|
func performChallengeFlow(ts *MFATestSuite, factorID uuid.UUID, token string) *httptest.ResponseRecorder {
|
|
var buffer bytes.Buffer
|
|
w := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), token, buffer)
|
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
|
return w
|
|
|
|
}
|
|
|
|
func performSMSChallengeFlow(ts *MFATestSuite, factorID uuid.UUID, token, channel string) *httptest.ResponseRecorder {
|
|
params := ChallengeFactorParams{
|
|
Channel: channel,
|
|
}
|
|
var buffer bytes.Buffer
|
|
if err := json.NewEncoder(&buffer).Encode(params); err != nil {
|
|
panic(err) // handle the error appropriately in real code
|
|
}
|
|
|
|
w := ServeAuthenticatedRequest(ts, http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), token, buffer)
|
|
require.Equal(ts.T(), http.StatusOK, w.Code)
|
|
return w
|
|
|
|
}
|
|
|
|
func performEnrollAndVerify(ts *MFATestSuite, token string, requireStatusOK bool) *httptest.ResponseRecorder {
|
|
w := performEnrollFlow(ts, token, "", models.TOTP, ts.TestDomain, "", http.StatusOK)
|
|
enrollResp := EnrollFactorResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
|
|
factorID := enrollResp.ID
|
|
|
|
// Challenge
|
|
w = performChallengeFlow(ts, factorID, token)
|
|
|
|
challengeResp := EnrollFactorResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&challengeResp))
|
|
challengeID := challengeResp.ID
|
|
|
|
// Verify
|
|
y := performVerifyFlow(ts, challengeID, factorID, token, requireStatusOK)
|
|
|
|
return y
|
|
}
|
|
|
|
func (ts *MFATestSuite) TestVerificationHooks() {
|
|
type verificationHookTestCase struct {
|
|
desc string
|
|
enabled bool
|
|
uri string
|
|
hookFunctionSQL string
|
|
emailSuffix string
|
|
expectToken bool
|
|
expectedCode int
|
|
cleanupHookFunction string
|
|
}
|
|
cases := []verificationHookTestCase{
|
|
{
|
|
desc: "Default Success",
|
|
enabled: true,
|
|
uri: "pg-functions://postgres/auth/verification_hook",
|
|
hookFunctionSQL: `
|
|
create or replace function verification_hook(input jsonb)
|
|
returns json as $$
|
|
begin
|
|
return json_build_object('decision', 'continue');
|
|
end; $$ language plpgsql;`,
|
|
emailSuffix: "success",
|
|
expectToken: true,
|
|
expectedCode: http.StatusOK,
|
|
cleanupHookFunction: "verification_hook(input jsonb)",
|
|
},
|
|
{
|
|
desc: "Error",
|
|
enabled: true,
|
|
uri: "pg-functions://postgres/auth/test_verification_hook_error",
|
|
hookFunctionSQL: `
|
|
create or replace function test_verification_hook_error(input jsonb)
|
|
returns json as $$
|
|
begin
|
|
RAISE EXCEPTION 'Intentional Error for Testing';
|
|
end; $$ language plpgsql;`,
|
|
emailSuffix: "error",
|
|
expectToken: false,
|
|
expectedCode: http.StatusInternalServerError,
|
|
cleanupHookFunction: "test_verification_hook_error(input jsonb)",
|
|
},
|
|
{
|
|
desc: "Reject - Enabled",
|
|
enabled: true,
|
|
uri: "pg-functions://postgres/auth/verification_hook_reject",
|
|
hookFunctionSQL: `
|
|
create or replace function verification_hook_reject(input jsonb)
|
|
returns json as $$
|
|
begin
|
|
return json_build_object(
|
|
'decision', 'reject',
|
|
'message', 'authentication attempt rejected'
|
|
);
|
|
end; $$ language plpgsql;`,
|
|
emailSuffix: "reject_enabled",
|
|
expectToken: false,
|
|
expectedCode: http.StatusForbidden,
|
|
cleanupHookFunction: "verification_hook_reject(input jsonb)",
|
|
},
|
|
{
|
|
desc: "Reject - Disabled",
|
|
enabled: false,
|
|
uri: "pg-functions://postgres/auth/verification_hook_reject",
|
|
hookFunctionSQL: `
|
|
create or replace function verification_hook_reject(input jsonb)
|
|
returns json as $$
|
|
begin
|
|
return json_build_object(
|
|
'decision', 'reject',
|
|
'message', 'authentication attempt rejected'
|
|
);
|
|
end; $$ language plpgsql;`,
|
|
emailSuffix: "reject_disabled",
|
|
expectToken: true,
|
|
expectedCode: http.StatusOK,
|
|
cleanupHookFunction: "verification_hook_reject(input jsonb)",
|
|
},
|
|
{
|
|
desc: "Timeout",
|
|
enabled: true,
|
|
uri: "pg-functions://postgres/auth/test_verification_hook_timeout",
|
|
hookFunctionSQL: `
|
|
create or replace function test_verification_hook_timeout(input jsonb)
|
|
returns json as $$
|
|
begin
|
|
PERFORM pg_sleep(3);
|
|
return json_build_object(
|
|
'decision', 'continue'
|
|
);
|
|
end; $$ language plpgsql;`,
|
|
emailSuffix: "timeout",
|
|
expectToken: false,
|
|
expectedCode: http.StatusInternalServerError,
|
|
cleanupHookFunction: "test_verification_hook_timeout(input jsonb)",
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
ts.T().Run(c.desc, func(t *testing.T) {
|
|
ts.Config.Hook.MFAVerificationAttempt.Enabled = c.enabled
|
|
ts.Config.Hook.MFAVerificationAttempt.URI = c.uri
|
|
require.NoError(ts.T(), ts.Config.Hook.MFAVerificationAttempt.PopulateExtensibilityPoint())
|
|
|
|
err := ts.API.db.RawQuery(c.hookFunctionSQL).Exec()
|
|
require.NoError(t, err)
|
|
|
|
email := fmt.Sprintf("testemail_%s@gmail.com", c.emailSuffix)
|
|
password := "testpassword"
|
|
resp := performTestSignupAndVerify(ts, email, password, c.expectToken)
|
|
require.Equal(ts.T(), c.expectedCode, resp.Code)
|
|
accessTokenResp := &AccessTokenResponse{}
|
|
require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))
|
|
|
|
if c.expectToken {
|
|
require.NotEqual(t, "", accessTokenResp.Token)
|
|
} else {
|
|
require.Equal(t, "", accessTokenResp.Token)
|
|
}
|
|
|
|
cleanupHook(ts, c.cleanupHookFunction)
|
|
})
|
|
}
|
|
}
|
|
|
|
func cleanupHook(ts *MFATestSuite, hookName string) {
|
|
cleanupHookSQL := fmt.Sprintf("drop function if exists %s", hookName)
|
|
err := ts.API.db.RawQuery(cleanupHookSQL).Exec()
|
|
require.NoError(ts.T(), err)
|
|
}
|