diff --git a/cli/cliui/table.go b/cli/cliui/table.go index b4c97d49ce788..cd26c4318246e 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -153,10 +153,10 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) // Special type formatting. switch val := v.(type) { case time.Time: - v = val.Format(time.Stamp) + v = val.Format(time.RFC3339) case *time.Time: if val != nil { - v = val.Format(time.Stamp) + v = val.Format(time.RFC3339) } case fmt.Stringer: if val != nil { diff --git a/cli/cliui/table_test.go b/cli/cliui/table_test.go index 54fc307b73bd2..7e28eb28a336e 100644 --- a/cli/cliui/table_test.go +++ b/cli/cliui/table_test.go @@ -131,10 +131,10 @@ func Test_DisplayTable(t *testing.T) { t.Parallel() expected := ` -NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR -foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10 -bar 20 [a] bar1 21 bar3 23 {bar4 24 } Aug 2 15:49:10 -baz 30 [] baz1 31 baz3 33 {baz4 34 } Aug 2 15:49:10 +NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR +foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z +bar 20 [a] bar1 21 bar3 23 {bar4 24 } 2022-08-02T15:49:10Z +baz 30 [] baz1 31 baz3 33 {baz4 34 } 2022-08-02T15:49:10Z ` // Test with non-pointer values. @@ -158,10 +158,10 @@ baz 30 [] baz1 31 baz3 t.Parallel() expected := ` -NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR -bar 20 [a] bar1 21 bar3 23 {bar4 24 } Aug 2 15:49:10 -baz 30 [] baz1 31 baz3 33 {baz4 34 } Aug 2 15:49:10 -foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10 +NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR +bar 20 [a] bar1 21 bar3 23 {bar4 24 } 2022-08-02T15:49:10Z +baz 30 [] baz1 31 baz3 33 {baz4 34 } 2022-08-02T15:49:10Z +foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z ` out, err := cliui.DisplayTable(in, "name", nil) @@ -175,9 +175,9 @@ foo 10 [a b c] foo1 11 foo2 12 foo3 expected := ` NAME SUB 1 NAME SUB 3 INNER NAME TIME -foo foo1 foo3 Aug 2 15:49:10 -bar bar1 bar3 Aug 2 15:49:10 -baz baz1 baz3 Aug 2 15:49:10 +foo foo1 foo3 2022-08-02T15:49:10Z +bar bar1 bar3 2022-08-02T15:49:10Z +baz baz1 baz3 2022-08-02T15:49:10Z ` out, err := cliui.DisplayTable(in, "", []string{"name", "sub_1_name", "sub_3 inner name", "time"}) diff --git a/cli/root.go b/cli/root.go index ee2d67d788e64..0f533a5b1c9ff 100644 --- a/cli/root.go +++ b/cli/root.go @@ -93,6 +93,7 @@ func Core() []*cobra.Command { users(), versionCmd(), workspaceAgent(), + tokens(), } } diff --git a/cli/tokens.go b/cli/tokens.go new file mode 100644 index 0000000000000..3ad22f14f2ca9 --- /dev/null +++ b/cli/tokens.go @@ -0,0 +1,155 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" +) + +func tokens() *cobra.Command { + cmd := &cobra.Command{ + Use: "tokens", + Short: "Manage personal access tokens", + Long: "Tokens are used to authenticate automated clients to Coder.", + Aliases: []string{"token"}, + Example: formatExamples( + example{ + Description: "Create a token for automation", + Command: "coder tokens create", + }, + example{ + Description: "List your tokens", + Command: "coder tokens ls", + }, + example{ + Description: "Remove a token by ID", + Command: "coder tokens rm WuoWs4ZsMX", + }, + ), + } + cmd.AddCommand( + createToken(), + listTokens(), + removeToken(), + ) + + return cmd +} + +func createToken() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a tokens", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := CreateClient(cmd) + if err != nil { + return xerrors.Errorf("create codersdk client: %w", err) + } + + res, err := client.CreateToken(cmd.Context(), codersdk.Me) + if err != nil { + return xerrors.Errorf("create tokens: %w", err) + } + + cmd.Println(cliui.Styles.Wrap.Render( + "Here is your token. 🪄", + )) + cmd.Println() + cmd.Println(cliui.Styles.Code.Render(strings.TrimSpace(res.Key))) + cmd.Println() + cmd.Println(cliui.Styles.Wrap.Render( + fmt.Sprintf("You can use this token by setting the --%s CLI flag, the %s environment variable, or the %q HTTP header.", varToken, envSessionToken, codersdk.SessionTokenKey), + )) + + return nil + }, + } + + return cmd +} + +type tokenRow struct { + ID string `table:"ID"` + LastUsed time.Time `table:"Last Used"` + ExpiresAt time.Time `table:"Expires At"` + CreatedAt time.Time `table:"Created At"` +} + +func listTokens() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List tokens", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := CreateClient(cmd) + if err != nil { + return xerrors.Errorf("create codersdk client: %w", err) + } + + keys, err := client.GetTokens(cmd.Context(), codersdk.Me) + if err != nil { + return xerrors.Errorf("create tokens: %w", err) + } + + if len(keys) == 0 { + cmd.Println(cliui.Styles.Wrap.Render( + "No tokens found.", + )) + } + + var rows []tokenRow + for _, key := range keys { + rows = append(rows, tokenRow{ + ID: key.ID, + LastUsed: key.LastUsed, + ExpiresAt: key.ExpiresAt, + CreatedAt: key.CreatedAt, + }) + } + + out, err := cliui.DisplayTable(rows, "", nil) + if err != nil { + return err + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), out) + return err + }, + } + + return cmd +} + +func removeToken() *cobra.Command { + cmd := &cobra.Command{ + Use: "remove [id]", + Aliases: []string{"rm"}, + Short: "Delete a token", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := CreateClient(cmd) + if err != nil { + return xerrors.Errorf("create codersdk client: %w", err) + } + + err = client.DeleteAPIKey(cmd.Context(), codersdk.Me, args[0]) + if err != nil { + return xerrors.Errorf("delete api key: %w", err) + } + + cmd.Println(cliui.Styles.Wrap.Render( + "Token has been deleted.", + )) + + return nil + }, + } + + return cmd +} diff --git a/cli/tokens_test.go b/cli/tokens_test.go new file mode 100644 index 0000000000000..de591c7701c40 --- /dev/null +++ b/cli/tokens_test.go @@ -0,0 +1,66 @@ +package cli_test + +import ( + "bytes" + "regexp" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" +) + +func TestTokens(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // helpful empty response + cmd, root := clitest.New(t, "tokens", "ls") + clitest.SetupConfig(t, client, root) + buf := new(bytes.Buffer) + cmd.SetOut(buf) + err := cmd.Execute() + require.NoError(t, err) + res := buf.String() + require.Contains(t, res, "tokens found") + + cmd, root = clitest.New(t, "tokens", "create") + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + cmd.SetOut(buf) + err = cmd.Execute() + require.NoError(t, err) + res = buf.String() + require.NotEmpty(t, res) + // find API key in format "XXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXX" + r := regexp.MustCompile("[a-zA-Z0-9]{10}-[a-zA-Z0-9]{22}") + require.Regexp(t, r, res) + key := r.FindString(res) + id := key[:10] + + cmd, root = clitest.New(t, "tokens", "ls") + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + cmd.SetOut(buf) + err = cmd.Execute() + require.NoError(t, err) + res = buf.String() + require.NotEmpty(t, res) + require.Contains(t, res, "ID") + require.Contains(t, res, "EXPIRES AT") + require.Contains(t, res, "CREATED AT") + require.Contains(t, res, "LAST USED") + require.Contains(t, res, id) + + cmd, root = clitest.New(t, "tokens", "rm", id) + clitest.SetupConfig(t, client, root) + buf = new(bytes.Buffer) + cmd.SetOut(buf) + err = cmd.Execute() + require.NoError(t, err) + res = buf.String() + require.NotEmpty(t, res) + require.Contains(t, res, "deleted") +} diff --git a/coderd/apikey.go b/coderd/apikey.go new file mode 100644 index 0000000000000..ba5c7c11aa8d3 --- /dev/null +++ b/coderd/apikey.go @@ -0,0 +1,235 @@ +package coderd + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "fmt" + "net" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/tabbed/pqtype" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/telemetry" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/cryptorand" +) + +// Creates a new token API key that effectively doesn't expire. +func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceAPIKey.WithOwner(user.ID.String())) { + httpapi.ResourceNotFound(rw) + return + } + + // tokens last 100 years + lifeTime := time.Hour * 876000 + cookie, err := api.createAPIKey(ctx, createAPIKeyParams{ + UserID: user.ID, + LoginType: database.LoginTypeToken, + ExpiresAt: database.Now().Add(lifeTime), + LifetimeSeconds: int64(lifeTime.Seconds()), + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create API key.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: cookie.Value}) +} + +func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + ) + + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceAPIKey.WithOwner(user.ID.String())) { + httpapi.ResourceNotFound(rw) + return + } + + keyID := chi.URLParam(r, "keyid") + key, err := api.Database.GetAPIKeyByID(ctx, keyID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching API key.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(key)) +} + +func (api *API) tokens(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + ) + + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceAPIKey.WithOwner(user.ID.String())) { + httpapi.ResourceNotFound(rw) + return + } + + keys, err := api.Database.GetAPIKeysByLoginType(ctx, database.LoginTypeToken) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusOK, []codersdk.APIKey{}) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching API keys.", + Detail: err.Error(), + }) + return + } + + var apiKeys []codersdk.APIKey + for _, key := range keys { + apiKeys = append(apiKeys, convertAPIKey(key)) + } + + httpapi.Write(ctx, rw, http.StatusOK, apiKeys) +} + +func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + ) + + if !api.Authorize(r, rbac.ActionDelete, rbac.ResourceAPIKey.WithOwner(user.ID.String())) { + httpapi.ResourceNotFound(rw) + return + } + + keyID := chi.URLParam(r, "keyid") + err := api.Database.DeleteAPIKeyByID(ctx, keyID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error deleting API key.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} + +// Generates a new ID and secret for an API key. +func generateAPIKeyIDSecret() (id string, secret string, err error) { + // Length of an API Key ID. + id, err = cryptorand.String(10) + if err != nil { + return "", "", err + } + // Length of an API Key secret. + secret, err = cryptorand.String(22) + if err != nil { + return "", "", err + } + return id, secret, nil +} + +type createAPIKeyParams struct { + UserID uuid.UUID + RemoteAddr string + LoginType database.LoginType + + // Optional. + ExpiresAt time.Time + LifetimeSeconds int64 + Scope database.APIKeyScope +} + +func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*http.Cookie, error) { + keyID, keySecret, err := generateAPIKeyIDSecret() + if err != nil { + return nil, xerrors.Errorf("generate API key: %w", err) + } + hashed := sha256.Sum256([]byte(keySecret)) + + // Default expires at to now+lifetime, or just 24hrs if not set + if params.ExpiresAt.IsZero() { + if params.LifetimeSeconds != 0 { + params.ExpiresAt = database.Now().Add(time.Duration(params.LifetimeSeconds) * time.Second) + } else { + params.ExpiresAt = database.Now().Add(24 * time.Hour) + } + } + + host, _, _ := net.SplitHostPort(params.RemoteAddr) + ip := net.ParseIP(host) + if ip == nil { + ip = net.IPv4(0, 0, 0, 0) + } + bitlen := len(ip) * 8 + + scope := database.APIKeyScopeAll + if params.Scope != "" { + scope = params.Scope + } + + key, err := api.Database.InsertAPIKey(ctx, database.InsertAPIKeyParams{ + ID: keyID, + UserID: params.UserID, + LifetimeSeconds: params.LifetimeSeconds, + IPAddress: pqtype.Inet{ + IPNet: net.IPNet{ + IP: ip, + Mask: net.CIDRMask(bitlen, bitlen), + }, + Valid: true, + }, + // Make sure in UTC time for common time zone + ExpiresAt: params.ExpiresAt.UTC(), + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + HashedSecret: hashed[:], + LoginType: params.LoginType, + Scope: scope, + }) + if err != nil { + return nil, xerrors.Errorf("insert API key: %w", err) + } + + api.Telemetry.Report(&telemetry.Snapshot{ + APIKeys: []telemetry.APIKey{telemetry.ConvertAPIKey(key)}, + }) + + // This format is consumed by the APIKey middleware. + sessionToken := fmt.Sprintf("%s-%s", keyID, keySecret) + return &http.Cookie{ + Name: codersdk.SessionTokenKey, + Value: sessionToken, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: api.SecureAuthCookie, + }, nil +} diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go new file mode 100644 index 0000000000000..8fbccd783faf3 --- /dev/null +++ b/coderd/apikey_test.go @@ -0,0 +1,41 @@ +package coderd_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/testutil" +) + +func TestTokens(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + _ = coderdtest.CreateFirstUser(t, client) + keys, err := client.GetTokens(ctx, codersdk.Me) + require.NoError(t, err) + require.Empty(t, keys) + + res, err := client.CreateToken(ctx, codersdk.Me) + require.NoError(t, err) + require.Greater(t, len(res.Key), 2) + + keys, err = client.GetTokens(ctx, codersdk.Me) + require.NoError(t, err) + require.EqualValues(t, len(keys), 1) + require.Contains(t, res.Key, keys[0].ID) + // expires_at must be greater than 50 years + require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300)) + + err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID) + require.NoError(t, err) + keys, err = client.GetTokens(ctx, codersdk.Me) + require.NoError(t, err) + require.Empty(t, keys) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index 011f29927d92e..5a1719cd3790c 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -399,8 +399,14 @@ func New(options *Options) *API { r.Get("/roles", api.userRoles) r.Route("/keys", func(r chi.Router) { - r.Post("/", api.postAPIKey) - r.Get("/{keyid}", api.apiKey) + r.Route("/tokens", func(r chi.Router) { + r.Post("/", api.postToken) + r.Get("/", api.tokens) + }) + r.Route("/{keyid}", func(r chi.Router) { + r.Get("/", api.apiKey) + r.Delete("/", api.deleteAPIKey) + }) }) r.Route("/organizations", func(r chi.Router) { diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 8713562e2e5e7..bdc26fc53fac2 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -279,6 +279,19 @@ func (q *fakeQuerier) GetAPIKeysLastUsedAfter(_ context.Context, after time.Time return apiKeys, nil } +func (q *fakeQuerier) GetAPIKeysByLoginType(_ context.Context, t database.LoginType) ([]database.APIKey, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + apiKeys := make([]database.APIKey, 0) + for _, key := range q.apiKeys { + if key.LoginType == t { + apiKeys = append(apiKeys, key) + } + } + return apiKeys, nil +} + func (q *fakeQuerier) DeleteAPIKeyByID(_ context.Context, id string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 9413e0822d154..1c7c5b5f19477 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -33,7 +33,8 @@ CREATE TYPE log_source AS ENUM ( CREATE TYPE login_type AS ENUM ( 'password', 'github', - 'oidc' + 'oidc', + 'token' ); CREATE TYPE parameter_destination_scheme AS ENUM ( diff --git a/coderd/database/migrations/000057_api_key_token.down.sql b/coderd/database/migrations/000057_api_key_token.down.sql new file mode 100644 index 0000000000000..0d2e97aef130e --- /dev/null +++ b/coderd/database/migrations/000057_api_key_token.down.sql @@ -0,0 +1,2 @@ +-- You cannot safely remove values from enums https://www.postgresql.org/docs/current/datatype-enum.html +-- You cannot create a new type and do a rename because objects depend on this type now. diff --git a/coderd/database/migrations/000057_api_key_token.up.sql b/coderd/database/migrations/000057_api_key_token.up.sql new file mode 100644 index 0000000000000..bb03e2f70950b --- /dev/null +++ b/coderd/database/migrations/000057_api_key_token.up.sql @@ -0,0 +1 @@ +ALTER TYPE login_type ADD VALUE IF NOT EXISTS 'token'; diff --git a/coderd/database/models.go b/coderd/database/models.go index bfd7d3f7af1ad..9cfade4341c93 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -120,6 +120,7 @@ const ( LoginTypePassword LoginType = "password" LoginTypeGithub LoginType = "github" LoginTypeOIDC LoginType = "oidc" + LoginTypeToken LoginType = "token" ) func (e *LoginType) Scan(src interface{}) error { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 56c5f677115d3..c802485666edc 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -25,6 +25,7 @@ type querier interface { DeleteOldAgentStats(ctx context.Context) error DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) + GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) GetActiveUserCount(ctx context.Context) (int64, error) GetAuditLogCount(ctx context.Context, arg GetAuditLogCountParams) (int64, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 66a08f1ccfe66..6795f916c27a1 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -175,6 +175,45 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro return i, err } +const getAPIKeysByLoginType = `-- name: GetAPIKeysByLoginType :many +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE login_type = $1 +` + +func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) { + rows, err := q.db.QueryContext(ctx, getAPIKeysByLoginType, loginType) + if err != nil { + return nil, err + } + defer rows.Close() + var items []APIKey + for rows.Next() { + var i APIKey + if err := rows.Scan( + &i.ID, + &i.HashedSecret, + &i.UserID, + &i.LastUsed, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.LoginType, + &i.LifetimeSeconds, + &i.IPAddress, + &i.Scope, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getAPIKeysLastUsedAfter = `-- name: GetAPIKeysLastUsedAfter :many SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE last_used > $1 ` diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index 7ee97b3beaa79..4dfb39b30b3ed 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -11,6 +11,9 @@ LIMIT -- name: GetAPIKeysLastUsedAfter :many SELECT * FROM api_keys WHERE last_used > $1; +-- name: GetAPIKeysByLoginType :many +SELECT * FROM api_keys WHERE login_type = $1; + -- name: InsertAPIKey :one INSERT INTO api_keys ( diff --git a/coderd/users.go b/coderd/users.go index 17c788f72b8f2..286cbf7a44ebb 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -3,21 +3,17 @@ package coderd import ( "bytes" "context" - "crypto/sha256" "database/sql" "errors" "fmt" - "net" "net/http" "net/url" "os" "strings" - "time" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/google/uuid" - "github.com/tabbed/pqtype" "golang.org/x/xerrors" "cdr.dev/slog" @@ -31,7 +27,6 @@ import ( "github.com/coder/coder/coderd/userpassword" "github.com/coder/coder/coderd/util/slice" "github.com/coder/coder/codersdk" - "github.com/coder/coder/cryptorand" "github.com/coder/coder/examples" ) @@ -943,69 +938,6 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) { }) } -// Creates a new session key, used for logging in via the CLI. -func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user := httpmw.UserParam(r) - - if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceAPIKey.WithOwner(user.ID.String())) { - httpapi.ResourceNotFound(rw) - return - } - - lifeTime := time.Hour * 24 * 7 - cookie, err := api.createAPIKey(ctx, createAPIKeyParams{ - UserID: user.ID, - LoginType: database.LoginTypePassword, - RemoteAddr: r.RemoteAddr, - // All api generated keys will last 1 week. Browser login tokens have - // a shorter life. - ExpiresAt: database.Now().Add(lifeTime), - LifetimeSeconds: int64(lifeTime.Seconds()), - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to create API key.", - Detail: err.Error(), - }) - return - } - - // We intentionally do not set the cookie on the response here. - // Setting the cookie will couple the browser sesion to the API - // key we return here, meaning logging out of the website would - // invalid your CLI key. - httpapi.Write(ctx, rw, http.StatusCreated, codersdk.GenerateAPIKeyResponse{Key: cookie.Value}) -} - -func (api *API) apiKey(rw http.ResponseWriter, r *http.Request) { - var ( - ctx = r.Context() - user = httpmw.UserParam(r) - ) - - if !api.Authorize(r, rbac.ActionRead, rbac.ResourceAPIKey.WithOwner(user.ID.String())) { - httpapi.ResourceNotFound(rw) - return - } - - keyID := chi.URLParam(r, "keyid") - key, err := api.Database.GetAPIKeyByID(ctx, keyID) - if errors.Is(err, sql.ErrNoRows) { - httpapi.ResourceNotFound(rw) - return - } - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching API key.", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, convertAPIKey(key)) -} - // Clear the user's session cookie. func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -1078,99 +1010,6 @@ func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) { }) } -// Generates a new ID and secret for an API key. -func generateAPIKeyIDSecret() (id string, secret string, err error) { - // Length of an API Key ID. - id, err = cryptorand.String(10) - if err != nil { - return "", "", err - } - // Length of an API Key secret. - secret, err = cryptorand.String(22) - if err != nil { - return "", "", err - } - return id, secret, nil -} - -type createAPIKeyParams struct { - UserID uuid.UUID - RemoteAddr string - LoginType database.LoginType - - // Optional. - ExpiresAt time.Time - LifetimeSeconds int64 - Scope database.APIKeyScope -} - -func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*http.Cookie, error) { - keyID, keySecret, err := generateAPIKeyIDSecret() - if err != nil { - return nil, xerrors.Errorf("generate API key: %w", err) - } - hashed := sha256.Sum256([]byte(keySecret)) - - // Default expires at to now+lifetime, or just 24hrs if not set - if params.ExpiresAt.IsZero() { - if params.LifetimeSeconds != 0 { - params.ExpiresAt = database.Now().Add(time.Duration(params.LifetimeSeconds) * time.Second) - } else { - params.ExpiresAt = database.Now().Add(24 * time.Hour) - } - } - - host, _, _ := net.SplitHostPort(params.RemoteAddr) - ip := net.ParseIP(host) - if ip == nil { - ip = net.IPv4(0, 0, 0, 0) - } - bitlen := len(ip) * 8 - - scope := database.APIKeyScopeAll - if params.Scope != "" { - scope = params.Scope - } - - key, err := api.Database.InsertAPIKey(ctx, database.InsertAPIKeyParams{ - ID: keyID, - UserID: params.UserID, - LifetimeSeconds: params.LifetimeSeconds, - IPAddress: pqtype.Inet{ - IPNet: net.IPNet{ - IP: ip, - Mask: net.CIDRMask(bitlen, bitlen), - }, - Valid: true, - }, - // Make sure in UTC time for common time zone - ExpiresAt: params.ExpiresAt.UTC(), - CreatedAt: database.Now(), - UpdatedAt: database.Now(), - HashedSecret: hashed[:], - LoginType: params.LoginType, - Scope: scope, - }) - if err != nil { - return nil, xerrors.Errorf("insert API key: %w", err) - } - - api.Telemetry.Report(&telemetry.Snapshot{ - APIKeys: []telemetry.APIKey{telemetry.ConvertAPIKey(key)}, - }) - - // This format is consumed by the APIKey middleware. - sessionToken := fmt.Sprintf("%s-%s", keyID, keySecret) - return &http.Cookie{ - Name: codersdk.SessionTokenKey, - Value: sessionToken, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - Secure: api.SecureAuthCookie, - }, nil -} - type CreateUserRequest struct { codersdk.CreateUserRequest LoginType database.LoginType diff --git a/coderd/users_test.go b/coderd/users_test.go index e6c8d5da26e17..3a7a11b67074d 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -285,16 +285,15 @@ func TestPostLogin(t *testing.T) { require.NoError(t, err, "fetch login key") require.Equal(t, int64(86400), key.LifetimeSeconds, "default should be 86400") - // Generated tokens have a longer life - token, err := client.CreateAPIKey(ctx, admin.UserID.String()) - require.NoError(t, err, "make new api key") + // tokens have a longer life + token, err := client.CreateToken(ctx, codersdk.Me) + require.NoError(t, err, "make new token api key") split = strings.Split(token.Key, "-") apiKey, err := client.GetAPIKey(ctx, admin.UserID.String(), split[0]) require.NoError(t, err, "fetch api key") - require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24*6)), "api key lasts more than 6 days") - require.True(t, apiKey.ExpiresAt.After(key.ExpiresAt.Add(time.Hour)), "api key should be longer expires") - require.Greater(t, apiKey.LifetimeSeconds, key.LifetimeSeconds, "api key should have longer lifetime") + require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*438300)), "tokens lasts more than 50 years") + require.Greater(t, apiKey.LifetimeSeconds, key.LifetimeSeconds, "token should have longer lifetime") }) } @@ -1195,36 +1194,18 @@ func TestGetUsers(t *testing.T) { }) } -func TestPostAPIKey(t *testing.T) { +func TestPostTokens(t *testing.T) { t.Parallel() - t.Run("InvalidUser", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - _ = coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - client.SessionToken = "" - _, err := client.CreateAPIKey(ctx, codersdk.Me) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) - }) - - t.Run("Success", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - _ = coderdtest.CreateFirstUser(t, client) + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() - apiKey, err := client.CreateAPIKey(ctx, codersdk.Me) - require.NotNil(t, apiKey) - require.GreaterOrEqual(t, len(apiKey.Key), 2) - require.NoError(t, err) - }) + apiKey, err := client.CreateToken(ctx, codersdk.Me) + require.NotNil(t, apiKey) + require.GreaterOrEqual(t, len(apiKey.Key), 2) + require.NoError(t, err) } func TestWorkspacesByUser(t *testing.T) { diff --git a/codersdk/apikey.go b/codersdk/apikey.go new file mode 100644 index 0000000000000..3be9a53b5d2df --- /dev/null +++ b/codersdk/apikey.go @@ -0,0 +1,87 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" +) + +type APIKey struct { + ID string `json:"id" validate:"required"` + // NOTE: do not ever return the HashedSecret + UserID uuid.UUID `json:"user_id" validate:"required"` + LastUsed time.Time `json:"last_used" validate:"required"` + ExpiresAt time.Time `json:"expires_at" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + UpdatedAt time.Time `json:"updated_at" validate:"required"` + LoginType LoginType `json:"login_type" validate:"required"` + LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` +} + +type LoginType string + +const ( + LoginTypePassword LoginType = "password" + LoginTypeGithub LoginType = "github" + LoginTypeOIDC LoginType = "oidc" + LoginTypeToken LoginType = "token" +) + +// CreateToken generates an API key that doesn't expire. +func (c *Client) CreateToken(ctx context.Context, userID string) (*GenerateAPIKeyResponse, error) { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode > http.StatusCreated { + return nil, readBodyAsError(res) + } + apiKey := &GenerateAPIKeyResponse{} + return apiKey, json.NewDecoder(res.Body).Decode(apiKey) +} + +// GetTokens list machine API keys. +func (c *Client) GetTokens(ctx context.Context, userID string) ([]APIKey, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/tokens", userID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode > http.StatusOK { + return nil, readBodyAsError(res) + } + var apiKey = []APIKey{} + return apiKey, json.NewDecoder(res.Body).Decode(&apiKey) +} + +// GetAPIKey returns the api key by id. +func (c *Client) GetAPIKey(ctx context.Context, userID string, id string) (*APIKey, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/%s", userID, id), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode > http.StatusCreated { + return nil, readBodyAsError(res) + } + apiKey := &APIKey{} + return apiKey, json.NewDecoder(res.Body).Decode(apiKey) +} + +// DeleteAPIKey deletes API key by id. +func (c *Client) DeleteAPIKey(ctx context.Context, userID string, id string) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/keys/%s", userID, id), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode > http.StatusNoContent { + return readBodyAsError(res) + } + return nil +} diff --git a/codersdk/users.go b/codersdk/users.go index 4fce8a3a47bfe..a37e41d84ec5a 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -22,14 +22,6 @@ const ( UserStatusSuspended UserStatus = "suspended" ) -type LoginType string - -const ( - LoginTypePassword LoginType = "password" - LoginTypeGithub LoginType = "github" - LoginTypeOIDC LoginType = "oidc" -) - type UsersRequest struct { Search string `json:"search,omitempty" typescript:"-"` // Filter users by status. @@ -55,18 +47,6 @@ type User struct { AvatarURL string `json:"avatar_url"` } -type APIKey struct { - ID string `json:"id" validate:"required"` - // NOTE: do not ever return the HashedSecret - UserID uuid.UUID `json:"user_id" validate:"required"` - LastUsed time.Time `json:"last_used" validate:"required"` - ExpiresAt time.Time `json:"expires_at" validate:"required"` - CreatedAt time.Time `json:"created_at" validate:"required"` - UpdatedAt time.Time `json:"updated_at" validate:"required"` - LoginType LoginType `json:"login_type" validate:"required"` - LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` -} - type CreateFirstUserRequest struct { Email string `json:"email" validate:"required,email"` Username string `json:"username" validate:"required,username"` @@ -287,33 +267,6 @@ func (c *Client) GetUserRoles(ctx context.Context, user string) (UserRoles, erro return roles, json.NewDecoder(res.Body).Decode(&roles) } -// CreateAPIKey generates an API key for the user ID provided. -func (c *Client) CreateAPIKey(ctx context.Context, user string) (*GenerateAPIKeyResponse, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/keys", user), nil) - if err != nil { - return nil, err - } - defer res.Body.Close() - if res.StatusCode > http.StatusCreated { - return nil, readBodyAsError(res) - } - apiKey := &GenerateAPIKeyResponse{} - return apiKey, json.NewDecoder(res.Body).Decode(apiKey) -} - -func (c *Client) GetAPIKey(ctx context.Context, user string, id string) (*APIKey, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/keys/%s", user, id), nil) - if err != nil { - return nil, err - } - defer res.Body.Close() - if res.StatusCode > http.StatusCreated { - return nil, readBodyAsError(res) - } - apiKey := &APIKey{} - return apiKey, json.NewDecoder(res.Body).Decode(apiKey) -} - // LoginWithPassword creates a session token authenticating with an email and password. // Call `SetSessionToken()` to apply the newly acquired token to the client. func (c *Client) LoginWithPassword(ctx context.Context, req LoginWithPasswordRequest) (LoginWithPasswordResponse, error) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5bfe3b788348e..ce6fca90a0e3e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1,6 +1,6 @@ // Code generated by 'make coder/scripts/apitypings/main.go'. DO NOT EDIT. -// From codersdk/users.go +// From codersdk/apikey.go export interface APIKey { readonly id: string readonly user_id: string @@ -685,8 +685,8 @@ export type LogLevel = "debug" | "error" | "info" | "trace" | "warn" // From codersdk/provisionerdaemons.go export type LogSource = "provisioner" | "provisioner_daemon" -// From codersdk/users.go -export type LoginType = "github" | "oidc" | "password" +// From codersdk/apikey.go +export type LoginType = "github" | "oidc" | "password" | "token" // From codersdk/parameters.go export type ParameterDestinationScheme = "environment_variable" | "none" | "provisioner_variable"