diff --git a/cmd/admin.go b/cmd/admin.go
index 318c212d086..b913b817bdc 100644
--- a/cmd/admin.go
+++ b/cmd/admin.go
@@ -5,7 +5,6 @@
 package cmd
 
 import (
-	"context"
 	"errors"
 	"fmt"
 	"os"
@@ -16,20 +15,15 @@ import (
 	auth_model "code.gitea.io/gitea/models/auth"
 	"code.gitea.io/gitea/models/db"
 	repo_model "code.gitea.io/gitea/models/repo"
-	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/graceful"
 	"code.gitea.io/gitea/modules/log"
-	pwd "code.gitea.io/gitea/modules/password"
 	repo_module "code.gitea.io/gitea/modules/repository"
-	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/storage"
 	"code.gitea.io/gitea/modules/util"
 	auth_service "code.gitea.io/gitea/services/auth"
 	"code.gitea.io/gitea/services/auth/source/oauth2"
 	"code.gitea.io/gitea/services/auth/source/smtp"
 	repo_service "code.gitea.io/gitea/services/repository"
-	user_service "code.gitea.io/gitea/services/user"
 
 	"github.com/urfave/cli"
 )
@@ -48,147 +42,6 @@ var (
 		},
 	}
 
-	subcmdUser = cli.Command{
-		Name:  "user",
-		Usage: "Modify users",
-		Subcommands: []cli.Command{
-			microcmdUserCreate,
-			microcmdUserList,
-			microcmdUserChangePassword,
-			microcmdUserDelete,
-			microcmdUserGenerateAccessToken,
-		},
-	}
-
-	microcmdUserList = cli.Command{
-		Name:   "list",
-		Usage:  "List users",
-		Action: runListUsers,
-		Flags: []cli.Flag{
-			cli.BoolFlag{
-				Name:  "admin",
-				Usage: "List only admin users",
-			},
-		},
-	}
-
-	microcmdUserCreate = cli.Command{
-		Name:   "create",
-		Usage:  "Create a new user in database",
-		Action: runCreateUser,
-		Flags: []cli.Flag{
-			cli.StringFlag{
-				Name:  "name",
-				Usage: "Username. DEPRECATED: use username instead",
-			},
-			cli.StringFlag{
-				Name:  "username",
-				Usage: "Username",
-			},
-			cli.StringFlag{
-				Name:  "password",
-				Usage: "User password",
-			},
-			cli.StringFlag{
-				Name:  "email",
-				Usage: "User email address",
-			},
-			cli.BoolFlag{
-				Name:  "admin",
-				Usage: "User is an admin",
-			},
-			cli.BoolFlag{
-				Name:  "random-password",
-				Usage: "Generate a random password for the user",
-			},
-			cli.BoolFlag{
-				Name:  "must-change-password",
-				Usage: "Set this option to false to prevent forcing the user to change their password after initial login, (Default: true)",
-			},
-			cli.IntFlag{
-				Name:  "random-password-length",
-				Usage: "Length of the random password to be generated",
-				Value: 12,
-			},
-			cli.BoolFlag{
-				Name:  "access-token",
-				Usage: "Generate access token for the user",
-			},
-			cli.BoolFlag{
-				Name:  "restricted",
-				Usage: "Make a restricted user account",
-			},
-		},
-	}
-
-	microcmdUserChangePassword = cli.Command{
-		Name:   "change-password",
-		Usage:  "Change a user's password",
-		Action: runChangePassword,
-		Flags: []cli.Flag{
-			cli.StringFlag{
-				Name:  "username,u",
-				Value: "",
-				Usage: "The user to change password for",
-			},
-			cli.StringFlag{
-				Name:  "password,p",
-				Value: "",
-				Usage: "New password to set for user",
-			},
-		},
-	}
-
-	microcmdUserDelete = cli.Command{
-		Name:  "delete",
-		Usage: "Delete specific user by id, name or email",
-		Flags: []cli.Flag{
-			cli.Int64Flag{
-				Name:  "id",
-				Usage: "ID of user of the user to delete",
-			},
-			cli.StringFlag{
-				Name:  "username,u",
-				Usage: "Username of the user to delete",
-			},
-			cli.StringFlag{
-				Name:  "email,e",
-				Usage: "Email of the user to delete",
-			},
-			cli.BoolFlag{
-				Name:  "purge",
-				Usage: "Purge user, all their repositories, organizations and comments",
-			},
-		},
-		Action: runDeleteUser,
-	}
-
-	microcmdUserGenerateAccessToken = cli.Command{
-		Name:  "generate-access-token",
-		Usage: "Generate a access token for a specific user",
-		Flags: []cli.Flag{
-			cli.StringFlag{
-				Name:  "username,u",
-				Usage: "Username",
-			},
-			cli.StringFlag{
-				Name:  "token-name,t",
-				Usage: "Token name",
-				Value: "gitea-admin",
-			},
-			cli.BoolFlag{
-				Name:  "raw",
-				Usage: "Display only the token value",
-			},
-			cli.StringFlag{
-				Name:  "scopes",
-				Value: "",
-				Usage: "Comma separated list of scopes to apply to access token",
-			},
-		},
-		Action: runGenerateAccessToken,
-	}
-
 	subcmdRepoSyncReleases = cli.Command{
 		Name:   "repo-sync-releases",
 		Usage:  "Synchronize repository releases with tags",
@@ -486,265 +339,6 @@ var (
 	}
 )
 
-func runChangePassword(c *cli.Context) error {
-	if err := argsSet(c, "username", "password"); err != nil {
-		return err
-	}
-
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-	if len(c.String("password")) < setting.MinPasswordLength {
-		return fmt.Errorf("Password is not long enough. Needs to be at least %d", setting.MinPasswordLength)
-	}
-
-	if !pwd.IsComplexEnough(c.String("password")) {
-		return errors.New("Password does not meet complexity requirements")
-	}
-	pwned, err := pwd.IsPwned(context.Background(), c.String("password"))
-	if err != nil {
-		return err
-	}
-	if pwned {
-		return errors.New("The password you chose is on a list of stolen passwords previously exposed in public data breaches. Please try again with a different password.\nFor more details, see https://haveibeenpwned.com/Passwords")
-	}
-	uname := c.String("username")
-	user, err := user_model.GetUserByName(ctx, uname)
-	if err != nil {
-		return err
-	}
-	if err = user.SetPassword(c.String("password")); err != nil {
-		return err
-	}
-
-	if err = user_model.UpdateUserCols(ctx, user, "passwd", "passwd_hash_algo", "salt"); err != nil {
-		return err
-	}
-
-	fmt.Printf("%s's password has been successfully updated!\n", user.Name)
-	return nil
-}
-
-func runCreateUser(c *cli.Context) error {
-	if err := argsSet(c, "email"); err != nil {
-		return err
-	}
-
-	if c.IsSet("name") && c.IsSet("username") {
-		return errors.New("Cannot set both --name and --username flags")
-	}
-	if !c.IsSet("name") && !c.IsSet("username") {
-		return errors.New("One of --name or --username flags must be set")
-	}
-
-	if c.IsSet("password") && c.IsSet("random-password") {
-		return errors.New("cannot set both -random-password and -password flags")
-	}
-
-	var username string
-	if c.IsSet("username") {
-		username = c.String("username")
-	} else {
-		username = c.String("name")
-		fmt.Fprintf(os.Stderr, "--name flag is deprecated. Use --username instead.\n")
-	}
-
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-
-	var password string
-	if c.IsSet("password") {
-		password = c.String("password")
-	} else if c.IsSet("random-password") {
-		var err error
-		password, err = pwd.Generate(c.Int("random-password-length"))
-		if err != nil {
-			return err
-		}
-		fmt.Printf("generated random password is '%s'\n", password)
-	} else {
-		return errors.New("must set either password or random-password flag")
-	}
-
-	// always default to true
-	changePassword := true
-
-	// If this is the first user being created.
-	// Take it as the admin and don't force a password update.
-	if n := user_model.CountUsers(nil); n == 0 {
-		changePassword = false
-	}
-
-	if c.IsSet("must-change-password") {
-		changePassword = c.Bool("must-change-password")
-	}
-
-	restricted := util.OptionalBoolNone
-
-	if c.IsSet("restricted") {
-		restricted = util.OptionalBoolOf(c.Bool("restricted"))
-	}
-
-	// default user visibility in app.ini
-	visibility := setting.Service.DefaultUserVisibilityMode
-
-	u := &user_model.User{
-		Name:               username,
-		Email:              c.String("email"),
-		Passwd:             password,
-		IsAdmin:            c.Bool("admin"),
-		MustChangePassword: changePassword,
-		Visibility:         visibility,
-	}
-
-	overwriteDefault := &user_model.CreateUserOverwriteOptions{
-		IsActive:     util.OptionalBoolTrue,
-		IsRestricted: restricted,
-	}
-
-	if err := user_model.CreateUser(u, overwriteDefault); err != nil {
-		return fmt.Errorf("CreateUser: %w", err)
-	}
-
-	if c.Bool("access-token") {
-		t := &auth_model.AccessToken{
-			Name: "gitea-admin",
-			UID:  u.ID,
-		}
-
-		if err := auth_model.NewAccessToken(t); err != nil {
-			return err
-		}
-
-		fmt.Printf("Access token was successfully created... %s\n", t.Token)
-	}
-
-	fmt.Printf("New user '%s' has been successfully created!\n", username)
-	return nil
-}
-
-func runListUsers(c *cli.Context) error {
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-
-	users, err := user_model.GetAllUsers()
-	if err != nil {
-		return err
-	}
-
-	w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0)
-
-	if c.IsSet("admin") {
-		fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\n")
-		for _, u := range users {
-			if u.IsAdmin {
-				fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", u.ID, u.Name, u.Email, u.IsActive)
-			}
-		}
-	} else {
-		twofa := user_model.UserList(users).GetTwoFaStatus()
-		fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\n")
-		for _, u := range users {
-			fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID])
-		}
-
-	}
-
-	w.Flush()
-	return nil
-}
-
-func runDeleteUser(c *cli.Context) error {
-	if !c.IsSet("id") && !c.IsSet("username") && !c.IsSet("email") {
-		return fmt.Errorf("You must provide the id, username or email of a user to delete")
-	}
-
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-
-	if err := storage.Init(); err != nil {
-		return err
-	}
-
-	var err error
-	var user *user_model.User
-	if c.IsSet("email") {
-		user, err = user_model.GetUserByEmail(c.String("email"))
-	} else if c.IsSet("username") {
-		user, err = user_model.GetUserByName(ctx, c.String("username"))
-	} else {
-		user, err = user_model.GetUserByID(ctx, c.Int64("id"))
-	}
-	if err != nil {
-		return err
-	}
-	if c.IsSet("username") && user.LowerName != strings.ToLower(strings.TrimSpace(c.String("username"))) {
-		return fmt.Errorf("The user %s who has email %s does not match the provided username %s", user.Name, c.String("email"), c.String("username"))
-	}
-
-	if c.IsSet("id") && user.ID != c.Int64("id") {
-		return fmt.Errorf("The user %s does not match the provided id %d", user.Name, c.Int64("id"))
-	}
-
-	return user_service.DeleteUser(ctx, user, c.Bool("purge"))
-}
-
-func runGenerateAccessToken(c *cli.Context) error {
-	if !c.IsSet("username") {
-		return fmt.Errorf("You must provide the username to generate a token for them")
-	}
-
-	ctx, cancel := installSignals()
-	defer cancel()
-
-	if err := initDB(ctx); err != nil {
-		return err
-	}
-
-	user, err := user_model.GetUserByName(ctx, c.String("username"))
-	if err != nil {
-		return err
-	}
-
-	accessTokenScope, err := auth_model.AccessTokenScope(c.String("scopes")).Normalize()
-	if err != nil {
-		return err
-	}
-
-	t := &auth_model.AccessToken{
-		Name:  c.String("token-name"),
-		UID:   user.ID,
-		Scope: accessTokenScope,
-	}
-
-	if err := auth_model.NewAccessToken(t); err != nil {
-		return err
-	}
-
-	if c.Bool("raw") {
-		fmt.Printf("%s\n", t.Token)
-	} else {
-		fmt.Printf("Access token was successfully created: %s\n", t.Token)
-	}
-
-	return nil
-}
-
 func runRepoSyncReleases(_ *cli.Context) error {
 	ctx, cancel := installSignals()
 	defer cancel()
diff --git a/cmd/admin_user.go b/cmd/admin_user.go
new file mode 100644
index 00000000000..a442b8fe9cc
--- /dev/null
+++ b/cmd/admin_user.go
@@ -0,0 +1,21 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	"github.com/urfave/cli"
+)
+
+var subcmdUser = cli.Command{
+	Name:  "user",
+	Usage: "Modify users",
+	Subcommands: []cli.Command{
+		microcmdUserCreate,
+		microcmdUserList,
+		microcmdUserChangePassword,
+		microcmdUserDelete,
+		microcmdUserGenerateAccessToken,
+		microcmdUserMustChangePassword,
+	},
+}
diff --git a/cmd/admin_user_change_password.go b/cmd/admin_user_change_password.go
new file mode 100644
index 00000000000..1b7c73370d4
--- /dev/null
+++ b/cmd/admin_user_change_password.go
@@ -0,0 +1,76 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	user_model "code.gitea.io/gitea/models/user"
+	pwd "code.gitea.io/gitea/modules/password"
+	"code.gitea.io/gitea/modules/setting"
+
+	"github.com/urfave/cli"
+)
+
+var microcmdUserChangePassword = cli.Command{
+	Name:   "change-password",
+	Usage:  "Change a user's password",
+	Action: runChangePassword,
+	Flags: []cli.Flag{
+		cli.StringFlag{
+			Name:  "username,u",
+			Value: "",
+			Usage: "The user to change password for",
+		},
+		cli.StringFlag{
+			Name:  "password,p",
+			Value: "",
+			Usage: "New password to set for user",
+		},
+	},
+}
+
+func runChangePassword(c *cli.Context) error {
+	if err := argsSet(c, "username", "password"); err != nil {
+		return err
+	}
+
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+	if len(c.String("password")) < setting.MinPasswordLength {
+		return fmt.Errorf("Password is not long enough. Needs to be at least %d", setting.MinPasswordLength)
+	}
+
+	if !pwd.IsComplexEnough(c.String("password")) {
+		return errors.New("Password does not meet complexity requirements")
+	}
+	pwned, err := pwd.IsPwned(context.Background(), c.String("password"))
+	if err != nil {
+		return err
+	}
+	if pwned {
+		return errors.New("The password you chose is on a list of stolen passwords previously exposed in public data breaches. Please try again with a different password.\nFor more details, see https://haveibeenpwned.com/Passwords")
+	}
+	uname := c.String("username")
+	user, err := user_model.GetUserByName(ctx, uname)
+	if err != nil {
+		return err
+	}
+	if err = user.SetPassword(c.String("password")); err != nil {
+		return err
+	}
+
+	if err = user_model.UpdateUserCols(ctx, user, "passwd", "passwd_hash_algo", "salt"); err != nil {
+		return err
+	}
+
+	fmt.Printf("%s's password has been successfully updated!\n", user.Name)
+	return nil
+}
diff --git a/cmd/admin_user_create.go b/cmd/admin_user_create.go
new file mode 100644
index 00000000000..579c6f2f62b
--- /dev/null
+++ b/cmd/admin_user_create.go
@@ -0,0 +1,169 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	"errors"
+	"fmt"
+	"os"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	user_model "code.gitea.io/gitea/models/user"
+	pwd "code.gitea.io/gitea/modules/password"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/util"
+
+	"github.com/urfave/cli"
+)
+
+var microcmdUserCreate = cli.Command{
+	Name:   "create",
+	Usage:  "Create a new user in database",
+	Action: runCreateUser,
+	Flags: []cli.Flag{
+		cli.StringFlag{
+			Name:  "name",
+			Usage: "Username. DEPRECATED: use username instead",
+		},
+		cli.StringFlag{
+			Name:  "username",
+			Usage: "Username",
+		},
+		cli.StringFlag{
+			Name:  "password",
+			Usage: "User password",
+		},
+		cli.StringFlag{
+			Name:  "email",
+			Usage: "User email address",
+		},
+		cli.BoolFlag{
+			Name:  "admin",
+			Usage: "User is an admin",
+		},
+		cli.BoolFlag{
+			Name:  "random-password",
+			Usage: "Generate a random password for the user",
+		},
+		cli.BoolFlag{
+			Name:  "must-change-password",
+			Usage: "Set this option to false to prevent forcing the user to change their password after initial login, (Default: true)",
+		},
+		cli.IntFlag{
+			Name:  "random-password-length",
+			Usage: "Length of the random password to be generated",
+			Value: 12,
+		},
+		cli.BoolFlag{
+			Name:  "access-token",
+			Usage: "Generate access token for the user",
+		},
+		cli.BoolFlag{
+			Name:  "restricted",
+			Usage: "Make a restricted user account",
+		},
+	},
+}
+
+func runCreateUser(c *cli.Context) error {
+	if err := argsSet(c, "email"); err != nil {
+		return err
+	}
+
+	if c.IsSet("name") && c.IsSet("username") {
+		return errors.New("Cannot set both --name and --username flags")
+	}
+	if !c.IsSet("name") && !c.IsSet("username") {
+		return errors.New("One of --name or --username flags must be set")
+	}
+
+	if c.IsSet("password") && c.IsSet("random-password") {
+		return errors.New("cannot set both -random-password and -password flags")
+	}
+
+	var username string
+	if c.IsSet("username") {
+		username = c.String("username")
+	} else {
+		username = c.String("name")
+		fmt.Fprintf(os.Stderr, "--name flag is deprecated. Use --username instead.\n")
+	}
+
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+
+	var password string
+	if c.IsSet("password") {
+		password = c.String("password")
+	} else if c.IsSet("random-password") {
+		var err error
+		password, err = pwd.Generate(c.Int("random-password-length"))
+		if err != nil {
+			return err
+		}
+		fmt.Printf("generated random password is '%s'\n", password)
+	} else {
+		return errors.New("must set either password or random-password flag")
+	}
+
+	// always default to true
+	changePassword := true
+
+	// If this is the first user being created.
+	// Take it as the admin and don't force a password update.
+	if n := user_model.CountUsers(nil); n == 0 {
+		changePassword = false
+	}
+
+	if c.IsSet("must-change-password") {
+		changePassword = c.Bool("must-change-password")
+	}
+
+	restricted := util.OptionalBoolNone
+
+	if c.IsSet("restricted") {
+		restricted = util.OptionalBoolOf(c.Bool("restricted"))
+	}
+
+	// default user visibility in app.ini
+	visibility := setting.Service.DefaultUserVisibilityMode
+
+	u := &user_model.User{
+		Name:               username,
+		Email:              c.String("email"),
+		Passwd:             password,
+		IsAdmin:            c.Bool("admin"),
+		MustChangePassword: changePassword,
+		Visibility:         visibility,
+	}
+
+	overwriteDefault := &user_model.CreateUserOverwriteOptions{
+		IsActive:     util.OptionalBoolTrue,
+		IsRestricted: restricted,
+	}
+
+	if err := user_model.CreateUser(u, overwriteDefault); err != nil {
+		return fmt.Errorf("CreateUser: %w", err)
+	}
+
+	if c.Bool("access-token") {
+		t := &auth_model.AccessToken{
+			Name: "gitea-admin",
+			UID:  u.ID,
+		}
+
+		if err := auth_model.NewAccessToken(t); err != nil {
+			return err
+		}
+
+		fmt.Printf("Access token was successfully created... %s\n", t.Token)
+	}
+
+	fmt.Printf("New user '%s' has been successfully created!\n", username)
+	return nil
+}
diff --git a/cmd/admin_user_delete.go b/cmd/admin_user_delete.go
new file mode 100644
index 00000000000..328d5feb61d
--- /dev/null
+++ b/cmd/admin_user_delete.go
@@ -0,0 +1,78 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	"fmt"
+	"strings"
+
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/storage"
+	user_service "code.gitea.io/gitea/services/user"
+
+	"github.com/urfave/cli"
+)
+
+var microcmdUserDelete = cli.Command{
+	Name:  "delete",
+	Usage: "Delete specific user by id, name or email",
+	Flags: []cli.Flag{
+		cli.Int64Flag{
+			Name:  "id",
+			Usage: "ID of user of the user to delete",
+		},
+		cli.StringFlag{
+			Name:  "username,u",
+			Usage: "Username of the user to delete",
+		},
+		cli.StringFlag{
+			Name:  "email,e",
+			Usage: "Email of the user to delete",
+		},
+		cli.BoolFlag{
+			Name:  "purge",
+			Usage: "Purge user, all their repositories, organizations and comments",
+		},
+	},
+	Action: runDeleteUser,
+}
+
+func runDeleteUser(c *cli.Context) error {
+	if !c.IsSet("id") && !c.IsSet("username") && !c.IsSet("email") {
+		return fmt.Errorf("You must provide the id, username or email of a user to delete")
+	}
+
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+
+	if err := storage.Init(); err != nil {
+		return err
+	}
+
+	var err error
+	var user *user_model.User
+	if c.IsSet("email") {
+		user, err = user_model.GetUserByEmail(c.String("email"))
+	} else if c.IsSet("username") {
+		user, err = user_model.GetUserByName(ctx, c.String("username"))
+	} else {
+		user, err = user_model.GetUserByID(ctx, c.Int64("id"))
+	}
+	if err != nil {
+		return err
+	}
+	if c.IsSet("username") && user.LowerName != strings.ToLower(strings.TrimSpace(c.String("username"))) {
+		return fmt.Errorf("The user %s who has email %s does not match the provided username %s", user.Name, c.String("email"), c.String("username"))
+	}
+
+	if c.IsSet("id") && user.ID != c.Int64("id") {
+		return fmt.Errorf("The user %s does not match the provided id %d", user.Name, c.Int64("id"))
+	}
+
+	return user_service.DeleteUser(ctx, user, c.Bool("purge"))
+}
diff --git a/cmd/admin_user_generate_access_token.go b/cmd/admin_user_generate_access_token.go
new file mode 100644
index 00000000000..822bc5c2bc7
--- /dev/null
+++ b/cmd/admin_user_generate_access_token.go
@@ -0,0 +1,80 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	"fmt"
+
+	auth_model "code.gitea.io/gitea/models/auth"
+	user_model "code.gitea.io/gitea/models/user"
+
+	"github.com/urfave/cli"
+)
+
+var microcmdUserGenerateAccessToken = cli.Command{
+	Name:  "generate-access-token",
+	Usage: "Generate an access token for a specific user",
+	Flags: []cli.Flag{
+		cli.StringFlag{
+			Name:  "username,u",
+			Usage: "Username",
+		},
+		cli.StringFlag{
+			Name:  "token-name,t",
+			Usage: "Token name",
+			Value: "gitea-admin",
+		},
+		cli.BoolFlag{
+			Name:  "raw",
+			Usage: "Display only the token value",
+		},
+		cli.StringFlag{
+			Name:  "scopes",
+			Value: "",
+			Usage: "Comma separated list of scopes to apply to access token",
+		},
+	},
+	Action: runGenerateAccessToken,
+}
+
+func runGenerateAccessToken(c *cli.Context) error {
+	if !c.IsSet("username") {
+		return fmt.Errorf("You must provide a username to generate a token for")
+	}
+
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+
+	user, err := user_model.GetUserByName(ctx, c.String("username"))
+	if err != nil {
+		return err
+	}
+
+	accessTokenScope, err := auth_model.AccessTokenScope(c.String("scopes")).Normalize()
+	if err != nil {
+		return err
+	}
+
+	t := &auth_model.AccessToken{
+		Name:  c.String("token-name"),
+		UID:   user.ID,
+		Scope: accessTokenScope,
+	}
+
+	if err := auth_model.NewAccessToken(t); err != nil {
+		return err
+	}
+
+	if c.Bool("raw") {
+		fmt.Printf("%s\n", t.Token)
+	} else {
+		fmt.Printf("Access token was successfully created: %s\n", t.Token)
+	}
+
+	return nil
+}
diff --git a/cmd/admin_user_list.go b/cmd/admin_user_list.go
new file mode 100644
index 00000000000..85490331ed4
--- /dev/null
+++ b/cmd/admin_user_list.go
@@ -0,0 +1,60 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	"fmt"
+	"os"
+	"text/tabwriter"
+
+	user_model "code.gitea.io/gitea/models/user"
+
+	"github.com/urfave/cli"
+)
+
+var microcmdUserList = cli.Command{
+	Name:   "list",
+	Usage:  "List users",
+	Action: runListUsers,
+	Flags: []cli.Flag{
+		cli.BoolFlag{
+			Name:  "admin",
+			Usage: "List only admin users",
+		},
+	},
+}
+
+func runListUsers(c *cli.Context) error {
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+
+	users, err := user_model.GetAllUsers()
+	if err != nil {
+		return err
+	}
+
+	w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0)
+
+	if c.IsSet("admin") {
+		fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\n")
+		for _, u := range users {
+			if u.IsAdmin {
+				fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", u.ID, u.Name, u.Email, u.IsActive)
+			}
+		}
+	} else {
+		twofa := user_model.UserList(users).GetTwoFaStatus()
+		fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\n")
+		for _, u := range users {
+			fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID])
+		}
+	}
+
+	w.Flush()
+	return nil
+}
diff --git a/cmd/admin_user_must_change_password.go b/cmd/admin_user_must_change_password.go
new file mode 100644
index 00000000000..eb13fbcae5d
--- /dev/null
+++ b/cmd/admin_user_must_change_password.go
@@ -0,0 +1,58 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+	"errors"
+	"fmt"
+
+	user_model "code.gitea.io/gitea/models/user"
+
+	"github.com/urfave/cli"
+)
+
+var microcmdUserMustChangePassword = cli.Command{
+	Name:   "must-change-password",
+	Usage:  "Set the must change password flag for the provided users or all users",
+	Action: runMustChangePassword,
+	Flags: []cli.Flag{
+		cli.BoolFlag{
+			Name:  "all,A",
+			Usage: "All users must change password, except those explicitly excluded with --exclude",
+		},
+		cli.StringSliceFlag{
+			Name:  "exclude,e",
+			Usage: "Do not change the must-change-password flag for these users",
+		},
+		cli.BoolFlag{
+			Name:  "unset",
+			Usage: "Instead of setting the must-change-password flag, unset it",
+		},
+	},
+}
+
+func runMustChangePassword(c *cli.Context) error {
+	ctx, cancel := installSignals()
+	defer cancel()
+
+	if c.NArg() == 0 && !c.IsSet("all") {
+		return errors.New("either usernames or --all must be provided")
+	}
+
+	mustChangePassword := !c.Bool("unset")
+	all := c.Bool("all")
+	exclude := c.StringSlice("exclude")
+
+	if err := initDB(ctx); err != nil {
+		return err
+	}
+
+	n, err := user_model.SetMustChangePassword(ctx, all, mustChangePassword, c.Args(), exclude)
+	if err != nil {
+		return err
+	}
+
+	fmt.Printf("Updated %d users setting MustChangePassword to %t\n", n, mustChangePassword)
+	return nil
+}
diff --git a/docs/content/doc/usage/command-line.en-us.md b/docs/content/doc/usage/command-line.en-us.md
index 9b861a9da39..70efebd2036 100644
--- a/docs/content/doc/usage/command-line.en-us.md
+++ b/docs/content/doc/usage/command-line.en-us.md
@@ -99,6 +99,13 @@ Admin operations:
         - `--password value`, `-p value`: New password. Required.
       - Examples:
         - `gitea admin user change-password --username myname --password asecurepassword`
+    - `must-change-password`:
+      - Args:
+        - `[username...]`: Users that must change their passwords
+      - Options:
+        - `--all`, `-A`: Force a password change for all users
+        - `--exclude username`, `-e username`: Exclude the given user. Can be set multiple times.
+        - `--unset`: Revoke forced password change for the given users
   - `regenerate`
     - Options:
       - `hooks`: Regenerate Git Hooks for all repositories
diff --git a/models/user/must_change_password.go b/models/user/must_change_password.go
new file mode 100644
index 00000000000..7eab08de892
--- /dev/null
+++ b/models/user/must_change_password.go
@@ -0,0 +1,49 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+	"context"
+	"strings"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/util"
+
+	"xorm.io/builder"
+)
+
+func SetMustChangePassword(ctx context.Context, all, mustChangePassword bool, include, exclude []string) (int64, error) {
+	sliceTrimSpaceDropEmpty := func(input []string) []string {
+		output := make([]string, 0, len(input))
+		for _, in := range input {
+			in = strings.ToLower(strings.TrimSpace(in))
+			if in == "" {
+				continue
+			}
+			output = append(output, in)
+		}
+		return output
+	}
+
+	var cond builder.Cond
+
+	// Only include the users where something changes to get an accurate count
+	cond = builder.Neq{"must_change_password": mustChangePassword}
+
+	if !all {
+		include = sliceTrimSpaceDropEmpty(include)
+		if len(include) == 0 {
+			return 0, util.NewSilentWrapErrorf(util.ErrInvalidArgument, "no users to include provided")
+		}
+
+		cond = cond.And(builder.In("lower_name", include))
+	}
+
+	exclude = sliceTrimSpaceDropEmpty(exclude)
+	if len(exclude) > 0 {
+		cond = cond.And(builder.NotIn("lower_name", exclude))
+	}
+
+	return db.GetEngine(ctx).Where(cond).MustCols("must_change_password").Update(&User{MustChangePassword: mustChangePassword})
+}