package apis_test import ( "net/http" "strings" "testing" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/types" ) func TestRecordAuthWithOTP(t *testing.T) { t.Parallel() scenarios := []tests.ApiScenario{ { Name: "not an auth collection", Method: http.MethodPost, URL: "/api/collections/demo1/auth-with-otp", Body: strings.NewReader(`{"otpId":"test","password":"123456"}`), ExpectedStatus: 404, ExpectedContent: []string{`"data":{}`}, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "auth collection with disabled otp", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", Body: strings.NewReader(`{"otpId":"test","password":"123456"}`), BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { usersCol, err := app.FindCollectionByNameOrId("users") if err != nil { t.Fatal(err) } usersCol.OTP.Enabled = false if err := app.Save(usersCol); err != nil { t.Fatal(err) } }, ExpectedStatus: 403, ExpectedContent: []string{`"data":{}`}, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "invalid body", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", Body: strings.NewReader(`{"email`), ExpectedStatus: 400, ExpectedContent: []string{`"data":{}`}, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "empty body", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", Body: strings.NewReader(``), ExpectedStatus: 400, ExpectedContent: []string{ `"data":{`, `"otpId":{"code":"validation_required"`, `"password":{"code":"validation_required"`, }, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "invalid request data", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", Body: strings.NewReader(`{ "otpId":"` + strings.Repeat("a", 256) + `", "password":"` + strings.Repeat("a", 72) + `" }`), ExpectedStatus: 400, ExpectedContent: []string{ `"data":{`, `"otpId":{"code":"validation_length_out_of_range"`, `"password":{"code":"validation_length_out_of_range"`, }, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "missing otp", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", Body: strings.NewReader(`{ "otpId":"missing", "password":"123456" }`), BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { user, err := app.FindAuthRecordByEmail("users", "test@example.com") if err != nil { t.Fatal(err) } otp := core.NewOTP(app) otp.Id = strings.Repeat("a", 15) otp.SetCollectionRef(user.Collection().Id) otp.SetRecordRef(user.Id) otp.SetPassword("123456") if err := app.Save(otp); err != nil { t.Fatal(err) } }, ExpectedStatus: 400, ExpectedContent: []string{`"data":{}`}, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "otp for different collection", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", Body: strings.NewReader(`{ "otpId":"` + strings.Repeat("a", 15) + `", "password":"123456" }`), BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { client, err := app.FindAuthRecordByEmail("clients", "test@example.com") if err != nil { t.Fatal(err) } otp := core.NewOTP(app) otp.Id = strings.Repeat("a", 15) otp.SetCollectionRef(client.Collection().Id) otp.SetRecordRef(client.Id) otp.SetPassword("123456") if err := app.Save(otp); err != nil { t.Fatal(err) } }, ExpectedStatus: 400, ExpectedContent: []string{`"data":{}`}, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "otp with wrong password", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", Body: strings.NewReader(`{ "otpId":"` + strings.Repeat("a", 15) + `", "password":"123456" }`), BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { user, err := app.FindAuthRecordByEmail("users", "test@example.com") if err != nil { t.Fatal(err) } otp := core.NewOTP(app) otp.Id = strings.Repeat("a", 15) otp.SetCollectionRef(user.Collection().Id) otp.SetRecordRef(user.Id) otp.SetPassword("1234567890") if err := app.Save(otp); err != nil { t.Fatal(err) } }, ExpectedStatus: 400, ExpectedContent: []string{`"data":{}`}, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "expired otp with valid password", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", Body: strings.NewReader(`{ "otpId":"` + strings.Repeat("a", 15) + `", "password":"123456" }`), BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { user, err := app.FindAuthRecordByEmail("users", "test@example.com") if err != nil { t.Fatal(err) } otp := core.NewOTP(app) otp.Id = strings.Repeat("a", 15) otp.SetCollectionRef(user.Collection().Id) otp.SetRecordRef(user.Id) otp.SetPassword("123456") expiredDate := types.NowDateTime().AddDate(-3, 0, 0) otp.SetRaw("created", expiredDate) otp.SetRaw("updated", expiredDate) if err := app.Save(otp); err != nil { t.Fatal(err) } }, ExpectedStatus: 400, ExpectedContent: []string{`"data":{}`}, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "valid otp with valid password", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", Body: strings.NewReader(`{ "otpId":"` + strings.Repeat("a", 15) + `", "password":"123456" }`), BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { user, err := app.FindAuthRecordByEmail("users", "test@example.com") if err != nil { t.Fatal(err) } otp := core.NewOTP(app) otp.Id = strings.Repeat("a", 15) otp.SetCollectionRef(user.Collection().Id) otp.SetRecordRef(user.Id) otp.SetPassword("123456") if err := app.Save(otp); err != nil { t.Fatal(err) } }, ExpectedStatus: 401, ExpectedContent: []string{`"mfaId":"`}, ExpectedEvents: map[string]int{ "*": 0, "OnRecordAuthWithOTPRequest": 1, "OnRecordAuthRequest": 1, // --- "OnModelValidate": 1, "OnModelCreate": 1, // mfa record "OnModelCreateExecute": 1, "OnModelAfterCreateSuccess": 1, "OnModelDelete": 1, // otp delete "OnModelDeleteExecute": 1, "OnModelAfterDeleteSuccess": 1, // --- "OnRecordValidate": 1, "OnRecordCreate": 1, "OnRecordCreateExecute": 1, "OnRecordAfterCreateSuccess": 1, "OnRecordDelete": 1, "OnRecordDeleteExecute": 1, "OnRecordAfterDeleteSuccess": 1, }, }, { Name: "valid otp with valid password (disabled MFA)", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", Body: strings.NewReader(`{ "otpId":"` + strings.Repeat("a", 15) + `", "password":"123456" }`), BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { user, err := app.FindAuthRecordByEmail("users", "test@example.com") if err != nil { t.Fatal(err) } user.Collection().MFA.Enabled = false if err := app.Save(user.Collection()); err != nil { t.Fatal(err) } otp := core.NewOTP(app) otp.Id = strings.Repeat("a", 15) otp.SetCollectionRef(user.Collection().Id) otp.SetRecordRef(user.Id) otp.SetPassword("123456") if err := app.Save(otp); err != nil { t.Fatal(err) } }, ExpectedStatus: 200, ExpectedContent: []string{ `"token":"`, `"record":{`, `"email":"test@example.com"`, }, NotExpectedContent: []string{ `"meta":`, // hidden fields `"tokenKey"`, `"password"`, }, ExpectedEvents: map[string]int{ "*": 0, "OnRecordAuthWithOTPRequest": 1, "OnRecordAuthRequest": 1, "OnRecordEnrich": 1, // --- "OnModelValidate": 1, "OnModelCreate": 1, // authOrigin "OnModelCreateExecute": 1, "OnModelAfterCreateSuccess": 1, "OnModelDelete": 1, // otp delete "OnModelDeleteExecute": 1, "OnModelAfterDeleteSuccess": 1, // --- "OnRecordValidate": 1, "OnRecordCreate": 1, "OnRecordCreateExecute": 1, "OnRecordAfterCreateSuccess": 1, "OnRecordDelete": 1, "OnRecordDeleteExecute": 1, "OnRecordAfterDeleteSuccess": 1, }, }, // rate limit checks // ----------------------------------------------------------- { Name: "RateLimit rule - users:authWithOTP", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { app.Settings().RateLimits.Enabled = true app.Settings().RateLimits.Rules = []core.RateLimitRule{ {MaxRequests: 100, Label: "abc"}, {MaxRequests: 100, Label: "*:authWithOTP"}, {MaxRequests: 100, Label: "users:auth"}, {MaxRequests: 0, Label: "users:authWithOTP"}, } }, ExpectedStatus: 429, ExpectedContent: []string{`"data":{}`}, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "RateLimit rule - *:authWithOTP", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { app.Settings().RateLimits.Enabled = true app.Settings().RateLimits.Rules = []core.RateLimitRule{ {MaxRequests: 100, Label: "abc"}, {MaxRequests: 100, Label: "*:auth"}, {MaxRequests: 0, Label: "*:authWithOTP"}, } }, ExpectedStatus: 429, ExpectedContent: []string{`"data":{}`}, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "RateLimit rule - users:auth", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { app.Settings().RateLimits.Enabled = true app.Settings().RateLimits.Rules = []core.RateLimitRule{ {MaxRequests: 100, Label: "abc"}, {MaxRequests: 100, Label: "*:authWithOTP"}, {MaxRequests: 0, Label: "users:auth"}, } }, ExpectedStatus: 429, ExpectedContent: []string{`"data":{}`}, ExpectedEvents: map[string]int{"*": 0}, }, { Name: "RateLimit rule - *:auth", Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { app.Settings().RateLimits.Enabled = true app.Settings().RateLimits.Rules = []core.RateLimitRule{ {MaxRequests: 100, Label: "abc"}, {MaxRequests: 0, Label: "*:auth"}, } }, ExpectedStatus: 429, ExpectedContent: []string{`"data":{}`}, ExpectedEvents: map[string]int{"*": 0}, }, } for _, scenario := range scenarios { scenario.Test(t) } } func TestRecordAuthWithOTPManualRateLimiterCheck(t *testing.T) { t.Parallel() var storeCache map[string]any otpAId := strings.Repeat("a", 15) otpBId := strings.Repeat("b", 15) scenarios := []struct { otpId string password string expectedStatus int }{ {otpAId, "12345", 400}, {otpAId, "12345", 400}, {otpAId, "12345", 400}, {otpAId, "12345", 400}, {otpAId, "123456", 429}, {otpBId, "12345", 400}, {otpBId, "123456", 200}, } for _, s := range scenarios { (&tests.ApiScenario{ Method: http.MethodPost, URL: "/api/collections/users/auth-with-otp", Body: strings.NewReader(`{ "otpId":"` + s.otpId + `", "password":"` + s.password + `" }`), BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) { for k, v := range storeCache { app.Store().Set(k, v) } user, err := app.FindAuthRecordByEmail("users", "test@example.com") if err != nil { t.Fatal(err) } user.Collection().MFA.Enabled = false if err := app.Save(user.Collection()); err != nil { t.Fatal(err) } for _, id := range []string{otpAId, otpBId} { otp := core.NewOTP(app) otp.Id = id otp.SetCollectionRef(user.Collection().Id) otp.SetRecordRef(user.Id) otp.SetPassword("123456") if err := app.Save(otp); err != nil { t.Fatal(err) } } }, ExpectedStatus: s.expectedStatus, ExpectedContent: []string{`"`}, // it doesn't matter anything non-empty AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) { storeCache = app.Store().GetAll() }, }).Test(t) } }