From 9cb6adab4db1eba55656a70cb5a017f11bb7f9e8 Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Fri, 8 Nov 2024 18:04:22 +0200 Subject: [PATCH] added superuser otp command --- CHANGELOG.md | 12 ++++++ cmd/superuser.go | 55 ++++++++++++++++++++++--- cmd/superuser_test.go | 93 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91c16b76..a9623234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## v0.23.0-rc13 (WIP) + +> [!CAUTION] +> **This is a prerelease intended for test and experimental purposes only!** + +- Added `superuser otp EMAIL` command as fallback for generating superuser OTPs from the command line in case OTP has been enabled for the `_superusers` but the SMTP server has deliverability issues. + +- Added `RateLimitRule.Audience` optional field for restricting a rate limit rule for `"@guest"`-only, `"@auth"`-only, `""`-any (default). + + ## v0.23.0-rc12 > [!CAUTION] @@ -342,6 +352,8 @@ For upgrading to PocketBase v0.23.0, please refer to: - New `POST /api/collections/{collection}/impersonate/{id}` endpoint. +- ⚠️ Removed `/api/admins/*` endpoints because admins are converted to `_superusers` auth collection records. + - ⚠️ Previously when uploading new files to a multiple `file` field, new files were automatically appended to the existing field values. This behaviour has changed with v0.23+ and for consistency with the other multi-valued fields when uploading new files they will replace the old ones. If you want to prepend or append new files to an existing multiple `file` field value you can use the `+` prefix or suffix: ```js diff --git a/cmd/superuser.go b/cmd/superuser.go index 398da7cd..54c1b9b1 100644 --- a/cmd/superuser.go +++ b/cmd/superuser.go @@ -7,6 +7,7 @@ import ( "github.com/fatih/color" "github.com/go-ozzo/ozzo-validation/v4/is" "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/security" "github.com/spf13/cobra" ) @@ -15,13 +16,14 @@ import ( func NewSuperuserCommand(app core.App) *cobra.Command { command := &cobra.Command{ Use: "superuser", - Short: "Manages superuser accounts", + Short: "Manage superusers", } command.AddCommand(superuserUpsertCommand(app)) command.AddCommand(superuserCreateCommand(app)) command.AddCommand(superuserUpdateCommand(app)) command.AddCommand(superuserDeleteCommand(app)) + command.AddCommand(superuserOTPCommand(app)) return command } @@ -30,7 +32,7 @@ func superuserUpsertCommand(app core.App) *cobra.Command { command := &cobra.Command{ Use: "upsert", Example: "superuser upsert test@example.com 1234567890", - Short: "Creates, or updates if email exists, a single superuser account", + Short: "Creates, or updates if email exists, a single superuser", SilenceUsage: true, RunE: func(command *cobra.Command, args []string) error { if len(args) != 2 { @@ -70,7 +72,7 @@ func superuserCreateCommand(app core.App) *cobra.Command { command := &cobra.Command{ Use: "create", Example: "superuser create test@example.com 1234567890", - Short: "Creates a new superuser account", + Short: "Creates a new superuser", SilenceUsage: true, RunE: func(command *cobra.Command, args []string) error { if len(args) != 2 { @@ -106,7 +108,7 @@ func superuserUpdateCommand(app core.App) *cobra.Command { command := &cobra.Command{ Use: "update", Example: "superuser update test@example.com 1234567890", - Short: "Changes the password of a single superuser account", + Short: "Changes the password of a single superuser", SilenceUsage: true, RunE: func(command *cobra.Command, args []string) error { if len(args) != 2 { @@ -140,7 +142,7 @@ func superuserDeleteCommand(app core.App) *cobra.Command { command := &cobra.Command{ Use: "delete", Example: "superuser delete test@example.com", - Short: "Deletes an existing superuser account", + Short: "Deletes an existing superuser", SilenceUsage: true, RunE: func(command *cobra.Command, args []string) error { if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil { @@ -164,3 +166,46 @@ func superuserDeleteCommand(app core.App) *cobra.Command { return command } + +func superuserOTPCommand(app core.App) *cobra.Command { + command := &cobra.Command{ + Use: "otp", + Example: "superuser otp test@example.com", + Short: "Creates a new OTP for the specified superuser", + SilenceUsage: true, + RunE: func(command *cobra.Command, args []string) error { + if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil { + return errors.New("Invalid or missing email address.") + } + + superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0]) + if err != nil { + return fmt.Errorf("Superuser with email %q doesn't exist.", args[0]) + } + + if !superuser.Collection().OTP.Enabled { + return errors.New("OTP is not enabled for the _superusers collection.") + } + + pass := security.RandomStringWithAlphabet(superuser.Collection().OTP.Length, "1234567890") + + otp := core.NewOTP(app) + otp.SetCollectionRef(superuser.Collection().Id) + otp.SetRecordRef(superuser.Id) + otp.SetPassword(pass) + + err = app.Save(otp) + if err != nil { + return fmt.Errorf("Failed to create OTP: %w", err) + } + + color.New(color.BgGreen, color.FgBlack).Printf("Successfully created OTP for superuser %q:", superuser.Email()) + color.Green("\n├─ Id: %s", otp.Id) + color.Green("├─ Pass: %s", pass) + color.Green("└─ Valid: %ds\n\n", superuser.Collection().OTP.Duration) + return nil + }, + } + + return command +} diff --git a/cmd/superuser_test.go b/cmd/superuser_test.go index 976279a5..dd3681d7 100644 --- a/cmd/superuser_test.go +++ b/cmd/superuser_test.go @@ -308,3 +308,96 @@ func TestSuperuserDeleteCommand(t *testing.T) { }) } } + +func TestSuperuserOTPCommand(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) + if err != nil { + t.Fatal(err) + } + + // remove all existing otps + otps, err := app.FindAllOTPsByCollection(superusersCollection) + if err != nil { + t.Fatal(err) + } + for _, otp := range otps { + err = app.Delete(otp) + if err != nil { + t.Fatal(err) + } + } + + scenarios := []struct { + name string + email string + enabled bool + expectError bool + }{ + { + "empty email", + "", + true, + true, + }, + { + "invalid email", + "invalid", + true, + true, + }, + { + "nonexisting superuser", + "test_missing@example.com", + true, + true, + }, + { + "existing superuser", + "test@example.com", + true, + false, + }, + { + "existing superuser with disabled OTP", + "test@example.com", + false, + true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + command := cmd.NewSuperuserCommand(app) + command.SetArgs([]string{"otp", s.email}) + + superusersCollection.OTP.Enabled = s.enabled + if err = app.SaveNoValidate(superusersCollection); err != nil { + t.Fatal(err) + } + + err := command.Execute() + + hasErr := err != nil + if s.expectError != hasErr { + t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err) + } + + if hasErr { + return + } + + superuser, err := app.FindAuthRecordByEmail(superusersCollection, s.email) + if err != nil { + t.Fatal(err) + } + + otps, _ := app.FindAllOTPsByRecord(superuser) + if total := len(otps); total != 1 { + t.Fatalf("Expected 1 OTP, got %d", total) + } + }) + } +}