diff --git a/cmd/token.go b/cmd/token.go new file mode 100644 index 00000000..df34ae09 --- /dev/null +++ b/cmd/token.go @@ -0,0 +1,212 @@ +//go:build !noserver + +package cmd + +import ( + "errors" + "fmt" + "github.com/urfave/cli/v2" + "heckel.io/ntfy/user" + "heckel.io/ntfy/util" + "net/netip" + "time" +) + +func init() { + commands = append(commands, cmdToken) +} + +var flagsToken = append( + flagsUser, +) + +var cmdToken = &cli.Command{ + Name: "token", + Usage: "Create, list or delete user tokens", + UsageText: "ntfy token [list|add|remove] ...", + Flags: flagsToken, + Before: initConfigFileInputSourceFunc("config", flagsToken, initLogFunc), + Category: categoryServer, + Subcommands: []*cli.Command{ + { + Name: "add", + Aliases: []string{"a"}, + Usage: "Create a new token", + UsageText: "ntfy token add [--expires=] [--label=..] USERNAME", + Action: execTokenAdd, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "expires", Aliases: []string{"e"}, Value: "", Usage: "token expires after"}, + &cli.StringFlag{Name: "label", Aliases: []string{"l"}, Value: "", Usage: "token label"}, + }, + Description: `Create a new user access token. + +User access tokens can be used to publish, subscribe, or perform any other user-specific tasks. +Tokens have full access, and can perform any task a user can do. They are meant to be used to +avoid spreading the password to various places. + +Examples: + ntfy token add phil # Create token for user phil which never expires + ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days + ntfy token add -e "tuesday, 8pm" phil # Create token for user phil which expires next Tuesday + ntfy token add -l backups phil # Create token for user phil with label "backups" +`, + }, + { + Name: "remove", + Aliases: []string{"del", "rm"}, + Usage: "Removes a token", + UsageText: "ntfy token remove USERNAME TOKEN", + Action: execTokenDel, + Description: `Remove a token from the ntfy user database. + +Example: + ntfy token del phil tk_th2srHVlxrANQHAso5t0HuQ1J1TjN +`, + }, + { + Name: "list", + Aliases: []string{"l"}, + Usage: "Shows a list of tokens", + Action: execTokenList, + Description: `Shows a list of all tokens. + +This is a server-only command. It directly reads from the user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. +`, + }, + }, + Description: `Manage access tokens for individual users. + +User access tokens can be used to publish, subscribe, or perform any other user-specific tasks. +Tokens have full access, and can perform any task a user can do. They are meant to be used to +avoid spreading the password to various places. + +This is a server-only command. It directly manages the user.db as defined in the server config +file server.yml. The command only works if 'auth-file' is properly defined. + +Examples: + ntfy token list # Shows list of tokens for all users + ntfy token list phil # Shows list of tokens for user phil + ntfy token add phil # Create token for user phil which never expires + ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days + ntfy token del phil tk_th2srHVlxr... # Delete token +`, +} + +func execTokenAdd(c *cli.Context) error { + username := c.Args().Get(0) + expiresStr := c.String("expires") + label := c.String("label") + if username == "" { + return errors.New("username expected, type 'ntfy token add --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + expires := time.Unix(0, 0) + if expiresStr != "" { + var err error + expires, err = util.ParseFutureTime(expiresStr, time.Now()) + if err != nil { + return err + } + } + manager, err := createUserManager(c) + if err != nil { + return err + } + u, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified()) + if err != nil { + return err + } + if expires.Unix() == 0 { + fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name) + } else { + fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate)) + } + return nil +} + +func execTokenDel(c *cli.Context) error { + username, token := c.Args().Get(0), c.Args().Get(1) + if username == "" || token == "" { + return errors.New("username and token expected, type 'ntfy token del --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + u, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + if err := manager.RemoveToken(u.ID, token); err != nil { + return err + } + fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username) + return nil +} + +func execTokenList(c *cli.Context) error { + username := c.Args().Get(0) + if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + var users []*user.User + if username != "" { + u, err := manager.User(username) + if err == user.ErrUserNotFound { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + users = append(users, u) + } else { + users, err = manager.Users() + if err != nil { + return err + } + } + if len(users) == 0 { + fmt.Fprintf(c.App.ErrWriter, "no users\n") + } else { + for _, u := range users { + tokens, err := manager.Tokens(u.ID) + if err != nil { + return err + } else if len(tokens) == 0 && username != "" { + fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username) + return nil + } else if len(tokens) == 0 { + continue + } + fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name) + for _, t := range tokens { + var label, expires string + if t.Label != "" { + label = fmt.Sprintf(" (%s)", t.Label) + } + if t.Expires.Unix() == 0 { + expires = "never expires" + } else { + expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822)) + } + fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822)) + } + } + } + return nil +} diff --git a/cmd/user.go b/cmd/user.go index e7c601c2..7e9ed50a 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -176,7 +176,7 @@ func execUserAdd(c *cli.Context) error { password := os.Getenv("NTFY_PASSWORD") if username == "" { return errors.New("username expected, type 'ntfy user add --help' for help") - } else if username == userEveryone { + } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } else if !user.AllowedRole(role) { return errors.New("role must be either 'user' or 'admin'") @@ -207,7 +207,7 @@ func execUserDel(c *cli.Context) error { username := c.Args().Get(0) if username == "" { return errors.New("username expected, type 'ntfy user del --help' for help") - } else if username == userEveryone { + } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } manager, err := createUserManager(c) @@ -229,7 +229,7 @@ func execUserChangePass(c *cli.Context) error { password := os.Getenv("NTFY_PASSWORD") if username == "" { return errors.New("username expected, type 'ntfy user change-pass --help' for help") - } else if username == userEveryone { + } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } manager, err := createUserManager(c) @@ -257,7 +257,7 @@ func execUserChangeRole(c *cli.Context) error { role := user.Role(c.Args().Get(1)) if username == "" || !user.AllowedRole(role) { return errors.New("username and new role expected, type 'ntfy user change-role --help' for help") - } else if username == userEveryone { + } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } manager, err := createUserManager(c) @@ -281,7 +281,7 @@ func execUserChangeTier(c *cli.Context) error { return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help") } else if !user.AllowedTier(tier) && tier != tierReset { return errors.New("invalid tier, must be tier code, or - to reset") - } else if username == userEveryone { + } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } manager, err := createUserManager(c) diff --git a/server/server.go b/server/server.go index 5ddbcac3..3021441e 100644 --- a/server/server.go +++ b/server/server.go @@ -36,6 +36,7 @@ import ( - HIGH Rate limiting: Sensitive endpoints (account/login/change-password/...) - HIGH Docs +- HIGH CLI - MEDIUM: Test new token endpoints & never-expiring token - MEDIUM: Make sure account endpoints make sense for admins - MEDIUM: Reservation (UI): Show "This topic is reserved" error message when trying to reserve a reserved topic (Thorben) diff --git a/user/manager.go b/user/manager.go index a3f79f69..51108661 100644 --- a/user/manager.go +++ b/user/manager.go @@ -27,7 +27,7 @@ const ( userHardDeleteAfterDuration = 7 * 24 * time.Hour tokenPrefix = "tk_" tokenLength = 32 - tokenMaxCount = 10 // Only keep this many tokens in the table per user + tokenMaxCount = 20 // Only keep this many tokens in the table per user ) // Default constants that may be overridden by configs