From 1c1e3a891992eba2f647cd8903613f9be1f362ba Mon Sep 17 00:00:00 2001 From: Thomas Miceli <27960254+thomiceli@users.noreply.github.com> Date: Sat, 24 Feb 2024 18:45:36 +0100 Subject: [PATCH] Reset a user password using CLI (#226) --- docs/administration/reset-password.md | 7 +++ internal/cli/admin.go | 50 ++++++++++++++++++ internal/cli/main.go | 2 +- internal/utils/argon2id.go | 76 +++++++++++++++++++++++++++ internal/web/auth.go | 4 +- internal/web/git_http.go | 5 +- internal/web/settings.go | 2 +- internal/web/util.go | 70 ------------------------ 8 files changed, 140 insertions(+), 76 deletions(-) create mode 100644 docs/administration/reset-password.md create mode 100644 internal/cli/admin.go create mode 100644 internal/utils/argon2id.go diff --git a/docs/administration/reset-password.md b/docs/administration/reset-password.md new file mode 100644 index 00000000..95dc140f --- /dev/null +++ b/docs/administration/reset-password.md @@ -0,0 +1,7 @@ +# Reset a user password + +To reset a user password, run the following command using the Opengist binary: + +```bash +./opengist admin reset-password +``` \ No newline at end of file diff --git a/internal/cli/admin.go b/internal/cli/admin.go new file mode 100644 index 00000000..c0fd533b --- /dev/null +++ b/internal/cli/admin.go @@ -0,0 +1,50 @@ +package cli + +import ( + "fmt" + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/utils" + "github.com/urfave/cli/v2" +) + +var CmdAdmin = cli.Command{ + Name: "admin", + Usage: "Admin commands", + Subcommands: []*cli.Command{ + &CmdAdminResetPassword, + }, +} + +var CmdAdminResetPassword = cli.Command{ + Name: "reset-password", + Usage: "Reset the password for a given user", + ArgsUsage: "[username] [password]", + Action: func(ctx *cli.Context) error { + initialize(ctx) + if ctx.NArg() < 2 { + return fmt.Errorf("username and password are required") + } + username := ctx.Args().Get(0) + plainPassword := ctx.Args().Get(1) + + user, err := db.GetUserByUsername(username) + if err != nil { + fmt.Printf("Cannot get user %s: %s\n", username, err) + return err + } + password, err := utils.Argon2id.Hash(plainPassword) + if err != nil { + fmt.Printf("Cannot hash password for user %s: %s\n", username, err) + return err + } + user.Password = password + + if err = user.Update(); err != nil { + fmt.Printf("Cannot update password for user %s: %s\n", username, err) + return err + } + + fmt.Printf("Password for user %s has been reset.\n", username) + return nil + }, +} diff --git a/internal/cli/main.go b/internal/cli/main.go index 3d8341c9..a78a6d87 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -48,7 +48,7 @@ func App() error { app.Usage = "A self-hosted pastebin powered by Git." app.HelpName = "opengist" - app.Commands = []*cli.Command{&CmdVersion, &CmdStart, &CmdHook} + app.Commands = []*cli.Command{&CmdVersion, &CmdStart, &CmdHook, &CmdAdmin} app.DefaultCommand = CmdStart.Name app.Flags = []cli.Flag{ &ConfigFlag, diff --git a/internal/utils/argon2id.go b/internal/utils/argon2id.go new file mode 100644 index 00000000..5cf15c27 --- /dev/null +++ b/internal/utils/argon2id.go @@ -0,0 +1,76 @@ +package utils + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "golang.org/x/crypto/argon2" + "strings" +) + +type Argon2ID struct { + format string + version int + time uint32 + memory uint32 + keyLen uint32 + saltLen uint32 + threads uint8 +} + +var Argon2id = Argon2ID{ + format: "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + version: argon2.Version, + time: 1, + memory: 64 * 1024, + keyLen: 32, + saltLen: 16, + threads: 4, +} + +func (a Argon2ID) Hash(plain string) (string, error) { + salt := make([]byte, a.saltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + + hash := argon2.IDKey([]byte(plain), salt, a.time, a.memory, a.threads, a.keyLen) + + return fmt.Sprintf(a.format, a.version, a.memory, a.time, a.threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(hash), + ), nil +} + +func (a Argon2ID) Verify(plain, hash string) (bool, error) { + if hash == "" { + return false, nil + } + + hashParts := strings.Split(hash, "$") + + if len(hashParts) != 6 { + return false, errors.New("invalid hash") + } + + _, err := fmt.Sscanf(hashParts[3], "m=%d,t=%d,p=%d", &a.memory, &a.time, &a.threads) + if err != nil { + return false, err + } + + salt, err := base64.RawStdEncoding.DecodeString(hashParts[4]) + if err != nil { + return false, err + } + + decodedHash, err := base64.RawStdEncoding.DecodeString(hashParts[5]) + if err != nil { + return false, err + } + + hashToCompare := argon2.IDKey([]byte(plain), salt, a.time, a.memory, a.threads, uint32(len(decodedHash))) + + return subtle.ConstantTimeCompare(decodedHash, hashToCompare) == 1, nil +} diff --git a/internal/web/auth.go b/internal/web/auth.go index afb24275..05d6edfc 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -74,7 +74,7 @@ func processRegister(ctx echo.Context) error { user := dto.ToUser() - password, err := argon2id.hash(user.Password) + password, err := utils.Argon2id.Hash(user.Password) if err != nil { return errorRes(500, "Cannot hash password", err) } @@ -129,7 +129,7 @@ func processLogin(ctx echo.Context) error { return redirect(ctx, "/login") } - if ok, err := argon2id.verify(password, user.Password); !ok { + if ok, err := utils.Argon2id.Verify(password, user.Password); !ok { if err != nil { return errorRes(500, "Cannot check for password", err) } diff --git a/internal/web/git_http.go b/internal/web/git_http.go index bd8d808d..dbce696a 100644 --- a/internal/web/git_http.go +++ b/internal/web/git_http.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "errors" "fmt" + "github.com/thomiceli/opengist/internal/utils" "net/http" "os" "os/exec" @@ -98,7 +99,7 @@ func gitHttp(ctx echo.Context) error { return plainText(ctx, 404, "Check your credentials or make sure you have access to the Gist") } - if ok, err := argon2id.verify(authPassword, gist.User.Password); !ok || gist.User.Username != authUsername { + if ok, err := utils.Argon2id.Verify(authPassword, gist.User.Password); !ok || gist.User.Username != authUsername { if err != nil { return errorRes(500, "Cannot verify password", err) } @@ -115,7 +116,7 @@ func gitHttp(ctx echo.Context) error { return errorRes(401, "Invalid credentials", nil) } - if ok, err := argon2id.verify(authPassword, user.Password); !ok { + if ok, err := utils.Argon2id.Verify(authPassword, user.Password); !ok { if err != nil { return errorRes(500, "Cannot check for password", err) } diff --git a/internal/web/settings.go b/internal/web/settings.go index 33c0a1ea..d09c284b 100644 --- a/internal/web/settings.go +++ b/internal/web/settings.go @@ -131,7 +131,7 @@ func passwordProcess(ctx echo.Context) error { return html(ctx, "settings.html") } - password, err := argon2id.hash(dto.Password) + password, err := utils.Argon2id.Hash(dto.Password) if err != nil { return errorRes(500, "Cannot hash password", err) } diff --git a/internal/web/util.go b/internal/web/util.go index 8adb65b0..2ec1af58 100644 --- a/internal/web/util.go +++ b/internal/web/util.go @@ -2,17 +2,12 @@ package web import ( "context" - "crypto/rand" - "crypto/subtle" - "encoding/base64" "errors" - "fmt" "github.com/gorilla/sessions" "github.com/labstack/echo/v4" "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/i18n" - "golang.org/x/crypto/argon2" "html/template" "net/http" "strconv" @@ -219,68 +214,3 @@ func addMetadataToSearchQuery(input, key, value string) string { return strings.TrimSpace(resultBuilder.String()) } - -type Argon2ID struct { - format string - version int - time uint32 - memory uint32 - keyLen uint32 - saltLen uint32 - threads uint8 -} - -var argon2id = Argon2ID{ - format: "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", - version: argon2.Version, - time: 1, - memory: 64 * 1024, - keyLen: 32, - saltLen: 16, - threads: 4, -} - -func (a Argon2ID) hash(plain string) (string, error) { - salt := make([]byte, a.saltLen) - if _, err := rand.Read(salt); err != nil { - return "", err - } - - hash := argon2.IDKey([]byte(plain), salt, a.time, a.memory, a.threads, a.keyLen) - - return fmt.Sprintf(a.format, a.version, a.memory, a.time, a.threads, - base64.RawStdEncoding.EncodeToString(salt), - base64.RawStdEncoding.EncodeToString(hash), - ), nil -} - -func (a Argon2ID) verify(plain, hash string) (bool, error) { - if hash == "" { - return false, nil - } - - hashParts := strings.Split(hash, "$") - - if len(hashParts) != 6 { - return false, errors.New("invalid hash") - } - - _, err := fmt.Sscanf(hashParts[3], "m=%d,t=%d,p=%d", &a.memory, &a.time, &a.threads) - if err != nil { - return false, err - } - - salt, err := base64.RawStdEncoding.DecodeString(hashParts[4]) - if err != nil { - return false, err - } - - decodedHash, err := base64.RawStdEncoding.DecodeString(hashParts[5]) - if err != nil { - return false, err - } - - hashToCompare := argon2.IDKey([]byte(plain), salt, a.time, a.memory, a.threads, uint32(len(decodedHash))) - - return subtle.ConstantTimeCompare(decodedHash, hashToCompare) == 1, nil -}