feat: tokens (#4380)

This commit is contained in:
Garrett Delfosse 2022-10-06 15:02:27 -04:00 committed by GitHub
parent fe7c9f8ec1
commit f5df54831a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 685 additions and 260 deletions

View File

@ -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 {

View File

@ -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 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
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 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
`
// Test with non-pointer values.
@ -158,10 +158,10 @@ baz 30 [] baz1 31 <nil> <nil> 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 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
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 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
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"})

View File

@ -93,6 +93,7 @@ func Core() []*cobra.Command {
users(),
versionCmd(),
workspaceAgent(),
tokens(),
}
}

155
cli/tokens.go Normal file
View File

@ -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
}

66
cli/tokens_test.go Normal file
View File

@ -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")
}

235
coderd/apikey.go Normal file
View File

@ -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
}

41
coderd/apikey_test.go Normal file
View File

@ -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)
}

View File

@ -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) {

View File

@ -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()

View File

@ -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 (

View File

@ -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.

View File

@ -0,0 +1 @@
ALTER TYPE login_type ADD VALUE IF NOT EXISTS 'token';

View File

@ -120,6 +120,7 @@ const (
LoginTypePassword LoginType = "password"
LoginTypeGithub LoginType = "github"
LoginTypeOIDC LoginType = "oidc"
LoginTypeToken LoginType = "token"
)
func (e *LoginType) Scan(src interface{}) error {

View File

@ -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)

View File

@ -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
`

View File

@ -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 (

View File

@ -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

View File

@ -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)
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()
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)
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) {

87
codersdk/apikey.go Normal file
View File

@ -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
}

View File

@ -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) {

View File

@ -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
@ -701,8 +701,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"