mirror of
https://github.com/kopia/kopia.git
synced 2026-05-24 14:44:47 -04:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
52
cli/command_user_hash_password.go
Normal file
52
cli/command_user_hash_password.go
Normal 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
|
||||
}
|
||||
104
cli/command_user_hash_password_test.go
Normal file
104
cli/command_user_hash_password_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user