Skip to content

feat: tokens #4380

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Oct 6, 2022
Prev Previous commit
Next Next commit
add tests
  • Loading branch information
f0ssel committed Oct 6, 2022
commit a097819f14797c23fcd6db7ba0d2f280b8749f55
4 changes: 2 additions & 2 deletions cli/cliui/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 13 additions & 7 deletions cli/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@ func createKey() *cobra.Command {
}

cmd.Println(cliui.Styles.Wrap.Render(
"This is your API key for authenticating to Coder in automated services. 🪄",
"Here is your API key. 🪄",
))
cmd.Println()
cmd.Println(cliui.Styles.Code.Render(strings.TrimSpace(res.Key)))
cmd.Println()
cmd.Println(cliui.Styles.Wrap.Render(
"You can use this API key by setting --%s CLI flag, the %s environment variable, or the %s HTTP header.",
fmt.Sprintf("You can use this API key by setting --%s CLI flag, the %s environment variable, or the \"%s\" HTTP header.", varToken, envSessionToken, codersdk.SessionTokenKey),
))

return nil
Expand All @@ -75,10 +75,10 @@ func createKey() *cobra.Command {
}

type keyRow struct {
ID string `table:"id"`
LastUsed time.Time `json:"last_used"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
ID string `table:"ID"`
LastUsed time.Time `table:"Last Used"`
ExpiresAt time.Time `table:"Expires At"`
CreatedAt time.Time `table:"Created At"`
}

func listKeys() *cobra.Command {
Expand All @@ -92,11 +92,17 @@ func listKeys() *cobra.Command {
return xerrors.Errorf("create codersdk client: %w", err)
}

keys, err := client.ListMachineKeys(cmd.Context(), codersdk.Me)
keys, err := client.GetMachineKeys(cmd.Context(), codersdk.Me)
if err != nil {
return xerrors.Errorf("create machine key: %w", err)
}

if len(keys) == 0 {
cmd.Println(cliui.Styles.Wrap.Render(
"No machine keys found.",
))
}

var rows []keyRow
for _, key := range keys {
rows = append(rows, keyRow{
Expand Down
66 changes: 66 additions & 0 deletions cli/keys_test.go
Original file line number Diff line number Diff line change
@@ -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 TestMachineKeys(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)

// helpful empty response
cmd, root := clitest.New(t, "keys", "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, "keys found")

cmd, root = clitest.New(t, "keys", "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, "keys", "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, "keys", "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")
}
239 changes: 239 additions & 0 deletions coderd/apikey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
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 machine session key that effectively doesn't expire.
func (api *API) postMachineAPIKey(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
}

// machine keys last 100 years
lifeTime := time.Hour * 876000
cookie, err := api.createAPIKey(ctx, createAPIKeyParams{
UserID: user.ID,
LoginType: database.LoginTypeMachine,
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))
}

func (api *API) machineKeys(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.LoginTypeMachine)
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
}
Loading