From 1e7a2aedb89d2f7f362e05db17408a3aa208feb3 Mon Sep 17 00:00:00 2001 From: Raphael 'kena' Poss Date: Fri, 10 Jan 2020 15:27:21 +0100 Subject: [PATCH] cli: new command `auth-session {login,logout,list}` tldr: this adds new CLI commands to log users in and out of the HTTP interface and produce a HTTP cookie for use in monitoring scripts. This is suitable for use by the `root` user without an Enterprise license. Also the new feature is client-side only, so the client binary with this feature can be used with a CockroachDB server/cluster running at an older version. **Motivation:** users who wish to use certain HTTP monitoring tools, in particular those that retrieve privileged information like logs, need a valid HTTP authentication token for an admin user. This token can be constructed by accessing the HTTP endpoint `/login`, however: - manually crafting the token using `/login` is cumbersome; - it's not possible to use `/login` for the `root` user; - it's not possible to create another admin user than `root` without a valid Enterprise license (because that requires role management). **Solution:** ``` cockroach auth-session login [--expire-after=...] [--only-cookie] cockroach auth-session logout cockroach auth-session list ``` - all three commands also support the standard SQL command-line arguments, e.g. `--url`, `--certs-dir`, `--echo-sql` and `--format`. - the `--expire-after` argument customizes the expiry period. The default is one hour. - the `--only-cookie` arguments limits the output of the command to just the HTTP cookie. By default, the session ID and the authentication cookie are printed using regular table formatting. Also see the two release notes below. Release note (cli change): Three new CLI commands `cockroach auth-session login`, `cockroach auth-session list` and `cockroach auth-session logout` are now provided to facilitate the management of web sessions. The command `auth-session login` also produces a HTTP cookie which can be used by non-interactive HTTP-based database management tools. It also can generate such a cookie for the `root` user, who would not otherwise be able to do so using a web browser. Release note (security update): The new command `cockroach auth-session login` (reserved to administrators) is able to create authentication tokens with an arbitrary expiration date. Operators should be careful to monitor `system.web_sessions` and enforce policy-mandated expirations either using SQL queries or the new command `cockroach auth-session logout`. --- pkg/cli/auth.go | 215 ++++++++++++++++++ pkg/cli/cli.go | 1 + pkg/cli/cli_test.go | 1 + pkg/cli/cliflags/flags.go | 13 ++ pkg/cli/context.go | 9 + pkg/cli/flags.go | 10 + pkg/cli/interactive_tests/test_auth_cookie.py | 18 ++ pkg/cli/interactive_tests/test_secure.tcl | 68 +++++- pkg/server/authentication.go | 29 ++- 9 files changed, 352 insertions(+), 12 deletions(-) create mode 100644 pkg/cli/auth.go create mode 100644 pkg/cli/interactive_tests/test_auth_cookie.py diff --git a/pkg/cli/auth.go b/pkg/cli/auth.go new file mode 100644 index 000000000000..11761dd683dd --- /dev/null +++ b/pkg/cli/auth.go @@ -0,0 +1,215 @@ +// Copyright 2020 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package cli + +import ( + "database/sql/driver" + "fmt" + "net/http" + "os" + + "github.com/cockroachdb/cockroach/pkg/server" + "github.com/cockroachdb/cockroach/pkg/server/serverpb" + "github.com/cockroachdb/cockroach/pkg/sql/sem/tree" + "github.com/cockroachdb/cockroach/pkg/util/timeutil" + "github.com/cockroachdb/errors" + "github.com/spf13/cobra" +) + +var loginCmd = &cobra.Command{ + Use: "login [options] ", + Short: "create a HTTP session and token for the given user", + Long: ` +Creates a HTTP session for the given user and print out a login cookie for use +in non-interactive programs. + +Example use of the session cookie using 'curl': + + curl -k -b "" https://localhost:8080/_admin/v1/settings + +The user invoking the 'login' CLI command must be an admin on the cluster. +The user for which the HTTP session is opened can be arbitrary. +`, + Args: cobra.ExactArgs(1), + RunE: MaybeDecorateGRPCError(runLogin), +} + +func runLogin(cmd *cobra.Command, args []string) error { + username := tree.Name(args[0]).Normalize() + id, httpCookie, err := createAuthSessionToken(username) + if err != nil { + return err + } + hC := httpCookie.String() + + if authCtx.onlyCookie { + // Simple format suitable for automation. + fmt.Println(hC) + } else { + // More complete format, suitable e.g. for appending to a CSV file + // with --format=csv. + cols := []string{"username", "session ID", "authentication cookie"} + rows := [][]string{ + {username, fmt.Sprintf("%d", id), hC}, + } + if err := printQueryOutput(os.Stdout, cols, newRowSliceIter(rows, "ll")); err != nil { + return err + } + + checkInteractive() + if cliCtx.isInteractive { + fmt.Fprintf(stderr, `# +# Example uses: +# +# curl [-k] --cookie '%s' https://... +# +# wget [--no-check-certificate] --header='Cookie: %s' https://... +# +`, hC, hC) + } + } + + return nil +} + +func createAuthSessionToken(username string) (sessionID int64, httpCookie *http.Cookie, err error) { + sqlConn, err := makeSQLClient("cockroach auth-session login", useSystemDb) + if err != nil { + return -1, nil, err + } + defer sqlConn.Close() + + // First things first. Does the user exist? + _, rows, err := runQuery(sqlConn, + makeQuery(`SELECT count(username) FROM system.users WHERE username = $1 AND NOT "isRole"`, username), false) + if err != nil { + return -1, nil, err + } + if rows[0][0] != "1" { + return -1, nil, fmt.Errorf("user %q does not exist", username) + } + + // Make a secret. + secret, hashedSecret, err := server.CreateAuthSecret() + if err != nil { + return -1, nil, err + } + expiration := timeutil.Now().Add(authCtx.validityPeriod) + + // Create the session on the server to the server. + insertSessionStmt := ` +INSERT INTO system.web_sessions ("hashedSecret", username, "expiresAt") +VALUES($1, $2, $3) +RETURNING id +` + var id int64 + row, err := sqlConn.QueryRow( + insertSessionStmt, + []driver.Value{ + hashedSecret, + username, + expiration, + }, + ) + if err != nil { + return -1, nil, err + } + if len(row) != 1 { + return -1, nil, errors.Newf("expected 1 column, got %d", len(row)) + } + id, ok := row[0].(int64) + if !ok { + return -1, nil, errors.Newf("expected integer, got %T", row[0]) + } + + // Spell out the cookie. + sCookie := &serverpb.SessionCookie{ID: id, Secret: secret} + httpCookie, err = server.EncodeSessionCookie(sCookie) + return id, httpCookie, err +} + +var logoutCmd = &cobra.Command{ + Use: "logout [options] ", + Short: "invalidates all the HTTP session tokens previously created for the given user", + Long: ` +Revokes all previously issued HTTP authentication tokens for the given user. + +The user invoking the 'login' CLI command must be an admin on the cluster. +The user for which the HTTP sessions are revoked can be arbitrary. +`, + Args: cobra.ExactArgs(1), + RunE: MaybeDecorateGRPCError(runLogout), +} + +func runLogout(cmd *cobra.Command, args []string) error { + username := tree.Name(args[0]).Normalize() + + sqlConn, err := makeSQLClient("cockroach auth-session logout", useSystemDb) + if err != nil { + return err + } + defer sqlConn.Close() + + logoutQuery := makeQuery( + `UPDATE system.web_sessions SET "revokedAt" = if("revokedAt"::timestamptz>expect-cmd.log 2>&1; + system "$argv start-single-node --host=localhost --certs-dir=$certs_dir --pid-file=server_pid -s=path=logs/db --background >>expect-cmd.log 2>&1; $argv sql --certs-dir=$certs_dir -e 'select 1'" report "END START SECURE SERVER" } @@ -127,10 +128,71 @@ eexpect "root@" interrupt end_test -# Terminate with Ctrl+C. +# Terminate the shell with Ctrl+C. interrupt +eexpect $prompt + +start_test "Check that an auth cookie cannot be created for a user that does not exist." +send "$argv auth-session login nonexistent --certs-dir=$certs_dir\r" +eexpect "user \"nonexistent\" does not exist" +eexpect $prompt +end_test + +start_test "Check that the auth cookie creation works and reports useful output." +send "$argv auth-session login eisen --certs-dir=$certs_dir\r" +eexpect "authentication cookie" +eexpect "session=" +eexpect "HttpOnly" +eexpect "Example uses:" +eexpect "curl" +eexpect "wget" +eexpect $prompt +end_test + +start_test "Check that the auth cookie can be emitted standalone." +send "$argv auth-session login eisen --certs-dir=$certs_dir --only-cookie >cookie.txt\r" +eexpect $prompt +system "grep HttpOnly cookie.txt" +end_test + +start_test "Check that the session is visible in the output of list." +send "$argv auth-session list --certs-dir=$certs_dir\r" +eexpect username +eexpect eisen +eexpect eisen +eexpect "2 rows" +eexpect $prompt +end_test + +set pyfile [file join [file dirname $argv0] test_auth_cookie.py] + +start_test "Check that the auth cookie works." +send "$python $pyfile cookie.txt 'https://localhost:8080/_admin/v1/settings'\r" +eexpect "cluster.organization" +eexpect $prompt +end_test + +start_test "Check that the cookie can be revoked." +send "$argv auth-session logout eisen --certs-dir=$certs_dir\r" +eexpect username +eexpect eisen +eexpect eisen +eexpect "2 rows" +eexpect "$prompt" + +send "$python $pyfile cookie.txt 'https://localhost:8080/_admin/v1/settings'\r" +eexpect "HTTP Error 401" +eexpect $prompt +end_test + +start_test "Check that a root cookie works." +send "$argv auth-session login root --certs-dir=$certs_dir --only-cookie >cookie.txt\r" +eexpect $prompt +send "$python $pyfile cookie.txt 'https://localhost:8080/_admin/v1/settings'\r" +eexpect "cluster.organization" eexpect $prompt +end_test send "exit 0\r" eexpect eof diff --git a/pkg/server/authentication.go b/pkg/server/authentication.go index 7af49a0eb15f..8d1cc5ba7522 100644 --- a/pkg/server/authentication.go +++ b/pkg/server/authentication.go @@ -43,8 +43,9 @@ const ( loginPath = "/login" logoutPath = "/logout" // secretLength is the number of random bytes generated for session secrets. - secretLength = 16 - sessionCookieName = "session" + secretLength = 16 + // SessionCookieName is the name of the cookie used for HTTP auth. + SessionCookieName = "session" ) var webSessionTimeout = settings.RegisterNonNegativeDurationSetting( @@ -271,19 +272,29 @@ func (s *authenticationServer) verifyPassword( return (security.CompareHashAndPassword(hashedPassword, password) == nil), nil } +// CreateAuthSecret creates a secret, hash pair to populate a session auth token. +func CreateAuthSecret() (secret, hashedSecret []byte, err error) { + secret = make([]byte, secretLength) + if _, err := rand.Read(secret); err != nil { + return nil, nil, err + } + + hasher := sha256.New() + _, _ = hasher.Write(secret) + hashedSecret = hasher.Sum(nil) + return secret, hashedSecret, nil +} + // newAuthSession attempts to create a new authentication session for the given // user. If successful, returns the ID and secret value for the new session. func (s *authenticationServer) newAuthSession( ctx context.Context, username string, ) (int64, []byte, error) { - secret := make([]byte, secretLength) - if _, err := rand.Read(secret); err != nil { + secret, hashedSecret, err := CreateAuthSecret() + if err != nil { return 0, nil, err } - hasher := sha256.New() - _, _ = hasher.Write(secret) - hashedSecret := hasher.Sum(nil) expiration := s.server.clock.PhysicalTime().Add(webSessionTimeout.Get(&s.server.st.SV)) insertSessionStmt := ` @@ -385,7 +396,7 @@ func EncodeSessionCookie(sessionCookie *serverpb.SessionCookie) (*http.Cookie, e func makeCookieWithValue(value string) *http.Cookie { return &http.Cookie{ - Name: sessionCookieName, + Name: SessionCookieName, Value: value, Path: "/", HttpOnly: true, @@ -400,7 +411,7 @@ func (am *authenticationMux) getSession( w http.ResponseWriter, req *http.Request, ) (string, *serverpb.SessionCookie, error) { // Validate the returned cookie. - rawCookie, err := req.Cookie(sessionCookieName) + rawCookie, err := req.Cookie(SessionCookieName) if err != nil { return "", nil, err }