feat(cli): add server user set-password-hash command (#3974)

Objectives:
- Facilitate the generation of valid password hashes that can be used with
  the `server user --user-password` CLI command.
- Encapsulate implementation details of password hashing in
  the `user` package.

Adds a new `server user hash-password` CLI command to generate the
hash from a supplied password.

Modifies the `server user set/add --user-password-hash` CLI command
to accept the password hash generated using the `hash-password`
command.

Adds `GetNewProfile(ctx, rep, username)` helper to move implementation
details to the `user` package.

Includes CLI and unit tests.

Cleans up and removes unused functions.
This commit is contained in:
Julio López
2024-07-11 19:29:06 -07:00
committed by GitHub
parent d0bff3004d
commit 9c5fc842a1
14 changed files with 456 additions and 111 deletions

View File

@@ -4,6 +4,7 @@ type commandServerUser struct {
add commandServerUserAddSet
set commandServerUserAddSet
delete commandServerUserDelete
hash commandServerUserHashPassword
info commandServerUserInfo
list commandServerUserList
}
@@ -14,6 +15,7 @@ func (c *commandServerUser) setup(svc appServices, parent commandParent) {
c.add.setup(svc, cmd, true)
c.set.setup(svc, cmd, false)
c.delete.setup(svc, cmd)
c.hash.setup(svc, cmd)
c.info.setup(svc, cmd)
c.list.setup(svc, cmd)
}

View File

@@ -2,7 +2,7 @@
import (
"context"
"encoding/base64"
"io"
"github.com/alecthomas/kingpin/v2"
"github.com/pkg/errors"
@@ -12,11 +12,10 @@
)
type commandServerUserAddSet struct {
userAskPassword bool
userSetName string
userSetPassword string
userSetPasswordHashAlgorithm string
userSetPasswordHash string
userAskPassword bool
userSetName string
userSetPassword string
userSetPasswordHash string
isNew bool // true == 'add', false == 'update'
out textOutput
@@ -36,7 +35,6 @@ func (c *commandServerUserAddSet) setup(svc appServices, parent commandParent, i
cmd.Flag("ask-password", "Ask for user password").BoolVar(&c.userAskPassword)
cmd.Flag("user-password", "Password").StringVar(&c.userSetPassword)
cmd.Flag("user-password-hash", "Password hash").StringVar(&c.userSetPasswordHash)
cmd.Flag("user-password-hashing-algorithm", "[Experimental] Password hashing algorithm").Hidden().Default(user.DefaultPasswordHashingAlgorithm).EnumVar(&c.userSetPasswordHashAlgorithm, user.PasswordHashingAlgorithms()...)
cmd.Arg("username", "Username").Required().StringVar(&c.userSetName)
cmd.Action(svc.repositoryWriterAction(c.runServerUserAddSet))
@@ -44,26 +42,14 @@ func (c *commandServerUserAddSet) setup(svc appServices, parent commandParent, i
}
func (c *commandServerUserAddSet) getExistingOrNewUserProfile(ctx context.Context, rep repo.Repository, username string) (*user.Profile, error) {
up, err := user.GetUserProfile(ctx, rep, username)
if c.isNew {
switch {
case err == nil:
return nil, errors.Errorf("user %q already exists", username)
up, err := user.GetNewProfile(ctx, rep, username)
case errors.Is(err, user.ErrUserNotFound):
passwordHashVersion, err := user.GetPasswordHashVersion(c.userSetPasswordHashAlgorithm)
if err != nil {
return nil, errors.Wrap(err, "failed to get password hash version")
}
return &user.Profile{
Username: username,
PasswordHashVersion: passwordHashVersion,
}, nil
}
return up, errors.Wrap(err, "error getting new user profile")
}
up, err := user.GetUserProfile(ctx, rep, username)
return up, errors.Wrap(err, "error getting user profile")
}
@@ -85,29 +71,18 @@ func (c *commandServerUserAddSet) runServerUserAddSet(ctx context.Context, rep r
}
}
if p := c.userSetPasswordHash; p != "" {
ph, err := base64.StdEncoding.DecodeString(p)
if err != nil {
return errors.Wrap(err, "invalid password hash, must be valid base64 string")
if ph := c.userSetPasswordHash; ph != "" {
if err := up.SetPasswordHash(ph); err != nil {
return errors.Wrap(err, "error setting password hash")
}
up.PasswordHash = ph
changed = true
}
if up.PasswordHash == nil || c.userAskPassword {
pwd, err := askPass(c.out.stdout(), "Enter new password for user "+username+": ")
pwd, err := askConfirmPass(c.out.stdout(), "Enter new password for user "+username+": ")
if err != nil {
return errors.Wrap(err, "error asking for password")
}
pwd2, err := askPass(c.out.stdout(), "Re-enter new password for verification: ")
if err != nil {
return errors.Wrap(err, "error asking for password")
}
if pwd != pwd2 {
return errors.Wrap(err, "passwords don't match")
return err
}
changed = true
@@ -132,3 +107,21 @@ func (c *commandServerUserAddSet) runServerUserAddSet(ctx context.Context, rep r
return nil
}
func askConfirmPass(out io.Writer, initialPrompt string) (string, error) {
pwd, err := askPass(out, initialPrompt)
if err != nil {
return "", errors.Wrap(err, "error asking for password")
}
pwd2, err := askPass(out, "Re-enter password for verification: ")
if err != nil {
return "", errors.Wrap(err, "error asking for password")
}
if pwd != pwd2 {
return "", errors.Wrap(err, "passwords don't match")
}
return pwd, nil
}

View File

@@ -0,0 +1,52 @@
package cli
import (
"context"
"github.com/pkg/errors"
"github.com/kopia/kopia/internal/user"
"github.com/kopia/kopia/repo"
)
type commandServerUserHashPassword struct {
password string
out textOutput
}
func (c *commandServerUserHashPassword) setup(svc appServices, parent commandParent) {
cmd := parent.Command("hash-password", "Hash a user password that can be passed to the 'server user add/set' command").Alias("hash")
cmd.Flag("user-password", "Password").StringVar(&c.password)
cmd.Action(svc.repositoryWriterAction(c.runServerUserHashPassword))
c.out.setup(svc)
}
// The current implementation does not require a connected repository, thus the
// RepositoryWriter parameter is not used. Future implementations will need a
// connected repository. To avoid a future incompatible change where the
// 'hash-password' command stops working without a connected repository,
// a connected repository is required now.
func (c *commandServerUserHashPassword) runServerUserHashPassword(ctx context.Context, _ repo.RepositoryWriter) error {
if c.password == "" {
// when password hash is empty, ask for password
pwd, err := askConfirmPass(c.out.stdout(), "Enter password to hash: ")
if err != nil {
return errors.Wrap(err, "error getting password")
}
c.password = pwd
}
h, err := user.HashPassword(c.password)
if err != nil {
return errors.Wrap(err, "hashing password")
}
c.out.printStdout("%s\n", h)
return nil
}

View File

@@ -0,0 +1,104 @@
package cli_test
import (
"math/rand"
"strconv"
"testing"
"github.com/stretchr/testify/require"
"github.com/kopia/kopia/internal/testutil"
"github.com/kopia/kopia/tests/testenv"
)
func TestServerUserHashPassword(t *testing.T) {
const (
userName = "user78"
userHost = "client-host"
userFull = userName + "@" + userHost
)
runner := testenv.NewInProcRunner(t)
e := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, runner)
e.RunAndExpectSuccess(t, "repo", "create", "filesystem", "--path", e.RepoDir, "--override-username", "server", "--override-hostname", "host")
t.Cleanup(func() {
e.RunAndExpectSuccess(t, "repo", "disconnect")
})
userPassword := "bad-password-" + strconv.Itoa(int(rand.Int31()))
out := e.RunAndExpectSuccess(t, "server", "users", "hash-password", "--user-password", userPassword)
require.Len(t, out, 1)
passwordHash := out[0]
require.NotEmpty(t, passwordHash)
// attempt to create a user with a bad password hash
e.RunAndExpectFailure(t, "server", "users", "add", userFull, "--user-password-hash", "bad-base64")
// create a new user with and set the password using the password hash
e.RunAndExpectSuccess(t, "server", "users", "add", userFull, "--user-password-hash", passwordHash)
// start server to test accessing the server with user created above
var sp testutil.ServerParameters
wait, kill := e.RunAndProcessStderr(t, sp.ProcessOutput,
"server", "start",
"--address=localhost:0",
"--tls-generate-cert",
"--random-server-control-password",
"--shutdown-grace-period", "100ms",
)
t.Cleanup(func() {
kill()
wait()
t.Log("server stopped")
})
t.Logf("detected server parameters %#v", sp)
// connect to the server repo using a client with the user created above
cr := testenv.NewInProcRunner(t)
clientEnv := testenv.NewCLITest(t, testenv.RepoFormatNotImportant, cr)
delete(clientEnv.Environment, "KOPIA_PASSWORD")
clientEnv.RunAndExpectSuccess(t, "repo", "connect", "server",
"--url", sp.BaseURL,
"--server-cert-fingerprint", sp.SHA256Fingerprint,
"--override-username", userName,
"--override-hostname", userHost,
"--password", userPassword)
clientEnv.RunAndExpectSuccess(t, "repo", "disconnect")
userPassword2 := "bad-password-" + strconv.Itoa(int(rand.Int31()))
out = e.RunAndExpectSuccess(t, "server", "users", "hash-password", "--user-password", userPassword2)
require.Len(t, out, 1)
passwordHash2 := out[0]
require.NotEmpty(t, passwordHash2)
// set new user password using the password hash and refresh the server
e.RunAndExpectSuccess(t, "server", "users", "set", userFull, "--user-password-hash", passwordHash2)
e.RunAndExpectSuccess(t, "server", "refresh",
"--address", sp.BaseURL,
"--server-cert-fingerprint", sp.SHA256Fingerprint,
"--server-control-password", sp.ServerControlPassword)
// attempt connecting with the new password
clientEnv.RunAndExpectSuccess(t, "repo", "connect", "server",
"--url", sp.BaseURL,
"--server-cert-fingerprint", sp.SHA256Fingerprint,
"--override-username", userName,
"--override-hostname", userHost,
"--password", userPassword2)
clientEnv.RunAndExpectSuccess(t, "repo", "disconnect")
}