Fix single sign-on from the web, and make passwords changeable
Why: two questions about operating this turned up two gaps. "Where do I get the admin password" had no answer once the line scrolled out of the log, and "how do I set up OIDC" ran straight into a sign-in link that could not work. What: - /oidc/login with no parameters now signs a browser in and ends in a session cookie. It previously required the user code of a waiting client, so the link on the web sign-in page always failed. With a code it still approves a device, and the two paths no longer overlap: an empty user code matches no pending device. - POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults. Changing a password requires the current one, and the one-time code when a second factor is enrolled, and revokes that account's other sessions. Until now nothing could change a password, which made the advice printed at first start impossible to follow. - keeporgd -reset-admin, for when that first password was not kept: a new one printed once, any second factor cleared, sessions revoked. It writes the state document directly and says to stop the server first. - The client read stdin through a new buffered reader per prompt, so the first prompt swallowed the rest and anything asking twice could not be scripted. One shared reader now. A prompt for a password being set no longer reads $KEEPORG_PASSWORD either; that variable holds the password you sign in with, and taking it here would have set the new password to the old one. - HANDOVER gains a first-sign-in section and an OIDC setup section covering the redirect URI, the config block, both entry points and how an identity becomes an account. Proved: OIDC now has tests, against a stub provider that serves discovery, authorization, token and userinfo. They cover the web sign-in that was broken, device approval, linking an existing account by username, the refusal when auto_create is off, and a forged callback state. Eight more cover the password change. Checked by hand with the real binaries: the first-start password logs once and works, -reset-admin replaces it while the old one stops working, and `keeporg passwd` changes it from a pipe. Server 0.5.0, desktop clients 0.3.0.
This commit is contained in:
+71
-6
@@ -242,6 +242,39 @@ func printStatus(st []client.Status) {
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func cmdPasswd(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("passwd", flag.ExitOnError)
|
||||
path := configFlag(fs)
|
||||
_ = fs.Parse(args)
|
||||
cfg, api, err := session(*path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Changing the account password for %s on %s.\n", cfg.Username, cfg.ServerURL)
|
||||
fmt.Println("This is not any vault's password; vault passwords are set in KeePass.")
|
||||
current, err := readPassword("Current password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := readNewAccountPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = api.ChangePassword(ctx, current, next, "")
|
||||
if client.Code(err) == "totp_required" || client.Code(err) == "totp_invalid" {
|
||||
code, cerr := prompt("One-time code: ")
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
err = api.ChangePassword(ctx, current, next, code)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("changed; this account's other sessions were signed out")
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdTOTP(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("totp", flag.ExitOnError)
|
||||
path := configFlag(fs)
|
||||
@@ -566,14 +599,19 @@ func cmdUsers(ctx context.Context, args []string) error {
|
||||
|
||||
// ---- terminal helpers ----
|
||||
|
||||
// stdin is read through one buffered reader for the whole process. A
|
||||
// reader per prompt would discard whatever it had buffered past the first
|
||||
// line, which loses typed-ahead input and breaks piped input entirely.
|
||||
var stdin = bufio.NewReader(os.Stdin)
|
||||
|
||||
func prompt(label string) (string, error) {
|
||||
fmt.Print(label)
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
line, err := stdin.ReadString('\n')
|
||||
return strings.TrimSpace(line), err
|
||||
}
|
||||
|
||||
// readPassword takes a password without echoing it. It prefers the
|
||||
// environment and a file so that scripts never need a terminal at all.
|
||||
// readPassword takes the account's existing password without echoing it. It
|
||||
// prefers the environment and a file so that scripts never need a terminal.
|
||||
func readPassword(label string) (string, error) {
|
||||
if v := os.Getenv("KEEPORG_PASSWORD"); v != "" {
|
||||
return v, nil
|
||||
@@ -582,27 +620,54 @@ func readPassword(label string) (string, error) {
|
||||
b, err := os.ReadFile(f)
|
||||
return strings.TrimSpace(string(b)), err
|
||||
}
|
||||
return readNewSecret(label)
|
||||
}
|
||||
|
||||
// readNewSecret asks for a secret that is being set rather than proven, so
|
||||
// it never reads KEEPORG_PASSWORD: that variable holds the password you are
|
||||
// signing in with, and taking it here would quietly set the new password to
|
||||
// the old one.
|
||||
func readNewSecret(label string) (string, error) {
|
||||
restore, hidden := hideInput()
|
||||
defer restore()
|
||||
fmt.Print(label)
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
line, err := stdin.ReadString('\n')
|
||||
if hidden {
|
||||
fmt.Println()
|
||||
}
|
||||
return strings.TrimSpace(line), err
|
||||
}
|
||||
|
||||
// readNewAccountPassword asks twice for an account password.
|
||||
func readNewAccountPassword() (string, error) {
|
||||
first, err := readNewSecret("New password: ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(first) < 10 {
|
||||
return "", errors.New("an account password must be at least 10 characters")
|
||||
}
|
||||
again, err := readNewSecret("Repeat: ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if first != again {
|
||||
return "", errors.New("the two passwords do not match")
|
||||
}
|
||||
return first, nil
|
||||
}
|
||||
|
||||
// readNewPassword asks twice, so a vault is never created with a password
|
||||
// its owner cannot reproduce.
|
||||
func readNewPassword(label string) (string, error) {
|
||||
first, err := readPassword(label)
|
||||
first, err := readNewSecret(label)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(first) < 12 {
|
||||
return "", errors.New("a vault password must be at least 12 characters")
|
||||
}
|
||||
again, err := readPassword("Repeat: ")
|
||||
again, err := readNewSecret("Repeat: ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ func main() {
|
||||
{"run", "keep the mirror in sync until stopped", cmdRun},
|
||||
{"sync", "reconcile once and exit", cmdSync},
|
||||
{"status", "show each mirrored database", cmdStatus},
|
||||
{"passwd", "change your account password", cmdPasswd},
|
||||
{"totp", "enrol a second factor for this account", cmdTOTP},
|
||||
{"whoami", "show the signed-in account and its groups", cmdWhoami},
|
||||
{"groups", "list groups you can see", cmdGroups},
|
||||
|
||||
+83
-1
@@ -90,7 +90,11 @@ func main() {
|
||||
showVer = flag.Bool("version", false, "print the version and exit")
|
||||
healthOnly = flag.Bool("healthcheck", false,
|
||||
"probe a running server on this address and exit 0 if it is healthy")
|
||||
writeCfg = flag.String("write-config", "", "write a commented example config to this path and exit")
|
||||
writeCfg = flag.String("write-config", "",
|
||||
"write a commented example config to this path and exit")
|
||||
resetAdmin = flag.Bool("reset-admin", false,
|
||||
"reset the built-in administrator's password, print a new one and "+
|
||||
"exit; stop the server first")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
@@ -139,6 +143,13 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if *resetAdmin {
|
||||
if err := resetAdministrator(cfg, lg); err != nil {
|
||||
lg.Fatalf("reset-admin: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
srv, err := server.New(cfg, lg)
|
||||
if err != nil {
|
||||
lg.Fatalf("start: %v", err)
|
||||
@@ -225,6 +236,77 @@ func bootstrap(srv *server.Server, lg *log.Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetAdministrator gives the server's administrator a new password, for
|
||||
// when the one printed at first start was not kept. It writes the state
|
||||
// document directly, so the server must be stopped: a running one holds the
|
||||
// state in memory and would write over this on its next change.
|
||||
//
|
||||
// Any second factor on that account is cleared too. Someone who has lost
|
||||
// the password has often lost the authenticator with it, and a recovery
|
||||
// that leaves you locked out is not one.
|
||||
func resetAdministrator(cfg server.Config, lg *log.Logger) error {
|
||||
cfg.Defaults()
|
||||
path := filepath.Join(cfg.DataDir, "state.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("no server state at %s: %w", path, err)
|
||||
}
|
||||
st, err := store.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password := os.Getenv("KEEPORG_ADMIN_PASSWORD")
|
||||
generated := false
|
||||
if password == "" {
|
||||
password = secret.Token()[:20]
|
||||
generated = true
|
||||
}
|
||||
hash, err := secret.Hash(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var name string
|
||||
if err := st.Update(func(s *store.State) error {
|
||||
u := s.UserByName("admin")
|
||||
if u == nil {
|
||||
// Whichever account can administer the server will do.
|
||||
for _, candidate := range s.Users {
|
||||
if candidate.ServerAdmin {
|
||||
u = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if u == nil {
|
||||
u = &store.User{ID: secret.ID(), Username: "admin", CreatedAt: time.Now()}
|
||||
s.Users[u.ID] = u
|
||||
}
|
||||
u.PassHash = hash
|
||||
u.TOTPSecret, u.TOTPConfirmed = "", false
|
||||
u.ServerAdmin, u.Disabled = true, false
|
||||
for token, sess := range s.Sessions {
|
||||
if sess.UserID == u.ID {
|
||||
delete(s.Sessions, token)
|
||||
}
|
||||
}
|
||||
name = u.Username
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if generated {
|
||||
lg.Printf("reset %q: password %q (shown once). Any second factor on that "+
|
||||
"account was cleared and its sessions were revoked.", name, password)
|
||||
} else {
|
||||
lg.Printf("reset %q to the password from KEEPORG_ADMIN_PASSWORD. Any second "+
|
||||
"factor on that account was cleared and its sessions were revoked.", name)
|
||||
}
|
||||
lg.Print("if keeporgd was running while this ran, restart it now so it reloads the change")
|
||||
return nil
|
||||
}
|
||||
|
||||
// healthcheck asks a server listening on this configuration whether it is
|
||||
// serving, and says why not when it is not.
|
||||
func healthcheck(cfg server.Config) error {
|
||||
|
||||
Reference in New Issue
Block a user