From a97fe643007acbeac8c6c1d169e3f45e01ee1b9d Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 31 May 2022 17:43:02 -0500 Subject: [PATCH 1/5] feat: Longer lived api keys for cli --- coderd/users.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/coderd/users.go b/coderd/users.go index 7769a67d53fdc..865f487adbc00 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -661,6 +661,9 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { sessionToken, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{ UserID: user.ID, LoginType: database.LoginTypePassword, + // All api generated keys will last 1 week. Browser login tokens have + // a shorter life. + ExpiresAt: database.Now().Add(time.Hour * 24 * 7), }) if !created { return @@ -721,10 +724,15 @@ func (api *API) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat } hashed := sha256.Sum256([]byte(keySecret)) + if params.ExpiresAt.IsZero() { + params.ExpiresAt = database.Now().Add(24 * time.Hour) + } + _, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ - ID: keyID, - UserID: params.UserID, - ExpiresAt: database.Now().Add(24 * time.Hour), + ID: keyID, + UserID: params.UserID, + // Make sure in UTC time for common time zone + ExpiresAt: params.ExpiresAt.UTC(), CreatedAt: database.Now(), UpdatedAt: database.Now(), HashedSecret: hashed[:], From b9cc661be31f1f91945607fc639a6eab83e4e763 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 31 May 2022 18:19:04 -0500 Subject: [PATCH 2/5] feat: Refresh tokens based on their lifetime set in the db --- coderd/database/databasefake/databasefake.go | 1 + coderd/database/dump.sql | 3 ++- .../database/migrations/000016_api_key_lifetime.down.sql | 1 + .../database/migrations/000016_api_key_lifetime.up.sql | 2 ++ coderd/database/models.go | 1 + coderd/database/queries.sql.go | 9 +++++++-- coderd/database/queries/apikeys.sql | 3 ++- coderd/httpmw/apikey.go | 6 +++++- coderd/users.go | 9 ++++++--- 9 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 coderd/database/migrations/000016_api_key_lifetime.down.sql create mode 100644 coderd/database/migrations/000016_api_key_lifetime.up.sql diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index aad1b333a5274..cd124fa569d21 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1129,6 +1129,7 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP //nolint:gosimple key := database.APIKey{ ID: arg.ID, + LifetimeSeconds: arg.LifetimeSeconds, HashedSecret: arg.HashedSecret, UserID: arg.UserID, ExpiresAt: arg.ExpiresAt, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index a36f04d003474..790ccea3f5395 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -93,7 +93,8 @@ CREATE TABLE api_keys ( oauth_access_token text DEFAULT ''::text NOT NULL, oauth_refresh_token text DEFAULT ''::text NOT NULL, oauth_id_token text DEFAULT ''::text NOT NULL, - oauth_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL + oauth_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, + lifetime_seconds bigint DEFAULT 86400 NOT NULL ); CREATE TABLE audit_logs ( diff --git a/coderd/database/migrations/000016_api_key_lifetime.down.sql b/coderd/database/migrations/000016_api_key_lifetime.down.sql new file mode 100644 index 0000000000000..7b96c62d78443 --- /dev/null +++ b/coderd/database/migrations/000016_api_key_lifetime.down.sql @@ -0,0 +1 @@ +ALTER TABLE api_keys DROP COLUMN lifetime_seconds; diff --git a/coderd/database/migrations/000016_api_key_lifetime.up.sql b/coderd/database/migrations/000016_api_key_lifetime.up.sql new file mode 100644 index 0000000000000..223e12c26691e --- /dev/null +++ b/coderd/database/migrations/000016_api_key_lifetime.up.sql @@ -0,0 +1,2 @@ +-- Default lifetime is 24hours. +ALTER TABLE api_keys ADD COLUMN lifetime_seconds bigint default 86400 NOT NULL; diff --git a/coderd/database/models.go b/coderd/database/models.go index dae316781354e..2b635026b057d 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -304,6 +304,7 @@ type APIKey struct { OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"` OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` + LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` } type AuditLog struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 29f3eb84f3272..4911dee2e27c7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -30,7 +30,7 @@ func (q *sqlQuerier) DeleteAPIKeyByID(ctx context.Context, id string) error { const getAPIKeyByID = `-- name: GetAPIKeyByID :one SELECT - id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry + id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds FROM api_keys WHERE @@ -55,6 +55,7 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro &i.OAuthRefreshToken, &i.OAuthIDToken, &i.OAuthExpiry, + &i.LifetimeSeconds, ) return i, err } @@ -66,6 +67,7 @@ INSERT INTO hashed_secret, user_id, last_used, + lifetime_seconds, expires_at, created_at, updated_at, @@ -76,7 +78,7 @@ INSERT INTO oauth_expiry ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds ` type InsertAPIKeyParams struct { @@ -84,6 +86,7 @@ type InsertAPIKeyParams struct { HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` UserID uuid.UUID `db:"user_id" json:"user_id"` LastUsed time.Time `db:"last_used" json:"last_used"` + LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` ExpiresAt time.Time `db:"expires_at" json:"expires_at"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` @@ -100,6 +103,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( arg.HashedSecret, arg.UserID, arg.LastUsed, + arg.LifetimeSeconds, arg.ExpiresAt, arg.CreatedAt, arg.UpdatedAt, @@ -123,6 +127,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( &i.OAuthRefreshToken, &i.OAuthIDToken, &i.OAuthExpiry, + &i.LifetimeSeconds, ) return i, err } diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index 38ac145ce465e..bce9198a58bc9 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -15,6 +15,7 @@ INSERT INTO hashed_secret, user_id, last_used, + lifetime_seconds, expires_at, created_at, updated_at, @@ -25,7 +26,7 @@ INSERT INTO oauth_expiry ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; -- name: UpdateAPIKeyByID :exec UPDATE diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 1e4098c0be431..78d0eadb59668 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -153,7 +153,11 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h } // Only update the ExpiresAt once an hour to prevent database spam. // We extend the ExpiresAt to reduce re-authentication. - apiKeyLifetime := 24 * time.Hour + apiKeyLifetime := time.Duration(key.LifetimeSeconds) * time.Second + if apiKeyLifetime == 0 { + // Default to 24 hours if it is not set + apiKeyLifetime = time.Hour * 24 + } if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour { key.ExpiresAt = now.Add(apiKeyLifetime) changed = true diff --git a/coderd/users.go b/coderd/users.go index 865f487adbc00..cebc08a9668c2 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -658,12 +658,14 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { return } + lifeTime := time.Hour * 24 * 7 sessionToken, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{ UserID: user.ID, LoginType: database.LoginTypePassword, // All api generated keys will last 1 week. Browser login tokens have // a shorter life. - ExpiresAt: database.Now().Add(time.Hour * 24 * 7), + ExpiresAt: database.Now().Add(lifeTime), + LifetimeSeconds: int64(lifeTime.Seconds()), }) if !created { return @@ -729,8 +731,9 @@ func (api *API) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat } _, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ - ID: keyID, - UserID: params.UserID, + ID: keyID, + UserID: params.UserID, + LifetimeSeconds: params.LifetimeSeconds, // Make sure in UTC time for common time zone ExpiresAt: params.ExpiresAt.UTC(), CreatedAt: database.Now(), From 147187274e60bbf5993f6750ce5cc20a1a1fc814 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 31 May 2022 18:49:52 -0500 Subject: [PATCH 3/5] test: Add unit test for refreshing --- coderd/database/databasefake/databasefake.go | 4 + coderd/database/queries.sql.go | 14 ++- coderd/database/queries/apikeys.sql | 10 +- coderd/httpmw/apikey.go | 4 - coderd/users_test.go | 96 ++++++++++++++++++++ 5 files changed, 118 insertions(+), 10 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index cd124fa569d21..6bf87ed3718eb 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1126,6 +1126,10 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP q.mutex.Lock() defer q.mutex.Unlock() + if arg.LifetimeSeconds == 0 { + arg.LifetimeSeconds = 86400 + } + //nolint:gosimple key := database.APIKey{ ID: arg.ID, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4911dee2e27c7..c87f688d5f46e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -64,10 +64,10 @@ const insertAPIKey = `-- name: InsertAPIKey :one INSERT INTO api_keys ( id, + lifetime_seconds, hashed_secret, user_id, last_used, - lifetime_seconds, expires_at, created_at, updated_at, @@ -78,15 +78,21 @@ INSERT INTO oauth_expiry ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds + ($1, + -- If the lifetime is set to 0, default to 24hrs + CASE $2::bigint + WHEN 0 THEN 86400 + ELSE $2::bigint + END + , $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds ` type InsertAPIKeyParams struct { ID string `db:"id" json:"id"` + LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` UserID uuid.UUID `db:"user_id" json:"user_id"` LastUsed time.Time `db:"last_used" json:"last_used"` - LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` ExpiresAt time.Time `db:"expires_at" json:"expires_at"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` @@ -100,10 +106,10 @@ type InsertAPIKeyParams struct { func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) { row := q.db.QueryRowContext(ctx, insertAPIKey, arg.ID, + arg.LifetimeSeconds, arg.HashedSecret, arg.UserID, arg.LastUsed, - arg.LifetimeSeconds, arg.ExpiresAt, arg.CreatedAt, arg.UpdatedAt, diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index bce9198a58bc9..9e7cc8e252b7d 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -12,10 +12,10 @@ LIMIT INSERT INTO api_keys ( id, + lifetime_seconds, hashed_secret, user_id, last_used, - lifetime_seconds, expires_at, created_at, updated_at, @@ -26,7 +26,13 @@ INSERT INTO oauth_expiry ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *; + (@id, + -- If the lifetime is set to 0, default to 24hrs + CASE @lifetime_seconds::bigint + WHEN 0 THEN 86400 + ELSE @lifetime_seconds::bigint + END + , @hashed_secret, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @oauth_access_token, @oauth_refresh_token, @oauth_id_token, @oauth_expiry) RETURNING *; -- name: UpdateAPIKeyByID :exec UPDATE diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 78d0eadb59668..e001abeebc80c 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -154,10 +154,6 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h // Only update the ExpiresAt once an hour to prevent database spam. // We extend the ExpiresAt to reduce re-authentication. apiKeyLifetime := time.Duration(key.LifetimeSeconds) * time.Second - if apiKeyLifetime == 0 { - // Default to 24 hours if it is not set - apiKeyLifetime = time.Hour * 24 - } if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour { key.ExpiresAt = now.Add(apiKeyLifetime) changed = true diff --git a/coderd/users_test.go b/coderd/users_test.go index 66ddb7ce6b771..f975665f4e94f 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -8,6 +8,9 @@ import ( "sort" "strings" "testing" + "time" + + "github.com/coder/coder/coderd/database" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -130,6 +133,99 @@ func TestPostLogin(t *testing.T) { }) require.NoError(t, err) }) + + t.Run("Lifetime&Expire", func(t *testing.T) { + t.Parallel() + var ( + ctx = context.Background() + ) + client, api := coderdtest.NewWithAPI(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + + split := strings.Split(client.SessionToken, "-") + loginKey, err := api.Database.GetAPIKeyByID(ctx, split[0]) + require.NoError(t, err, "fetch login key") + require.Equal(t, int64(86400), loginKey.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") + split = strings.Split(token.Key, "-") + apiKey, err := api.Database.GetAPIKeyByID(ctx, 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(loginKey.ExpiresAt.Add(time.Hour)), "api key should be longer expires") + require.Greater(t, apiKey.LifetimeSeconds, loginKey.LifetimeSeconds, "api key should have longer lifetime") + }) + + t.Run("APIKeyExtend", func(t *testing.T) { + t.Parallel() + var ( + ctx = context.Background() + ) + client, api := coderdtest.NewWithAPI(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + + token, err := client.CreateAPIKey(ctx, admin.UserID.String()) + require.NoError(t, err, "make new api key") + client.SessionToken = token.Key + split := strings.Split(token.Key, "-") + + apiKey, err := api.Database.GetAPIKeyByID(ctx, split[0]) + require.NoError(t, err, "fetch api key") + + err = api.Database.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{ + ID: apiKey.ID, + LastUsed: apiKey.LastUsed, + // This should cause a refresh + ExpiresAt: apiKey.ExpiresAt.Add(time.Hour * -2), + OAuthAccessToken: apiKey.OAuthAccessToken, + OAuthRefreshToken: apiKey.OAuthRefreshToken, + OAuthExpiry: apiKey.OAuthExpiry, + }) + require.NoError(t, err, "update api key") + + _, err = client.User(ctx, codersdk.Me) + require.NoError(t, err, "fetch user") + + apiKey, err = api.Database.GetAPIKeyByID(ctx, split[0]) + require.NoError(t, err, "fetch refreshed api key") + // 1 minute tolerance + require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24*7).Add(time.Minute*-1)), "api key lasts 7 days") + }) + + t.Run("LoginKeyExtend", func(t *testing.T) { + t.Parallel() + var ( + ctx = context.Background() + ) + client, api := coderdtest.NewWithAPI(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + split := strings.Split(client.SessionToken, "-") + + apiKey, err := api.Database.GetAPIKeyByID(ctx, split[0]) + require.NoError(t, err, "fetch login key") + + err = api.Database.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{ + ID: apiKey.ID, + LastUsed: apiKey.LastUsed, + // This should cause a refresh + ExpiresAt: apiKey.ExpiresAt.Add(time.Hour * -2), + OAuthAccessToken: apiKey.OAuthAccessToken, + OAuthRefreshToken: apiKey.OAuthRefreshToken, + OAuthExpiry: apiKey.OAuthExpiry, + }) + require.NoError(t, err, "update login key") + + _, err = client.User(ctx, codersdk.Me) + require.NoError(t, err, "fetch user") + + apiKey, err = api.Database.GetAPIKeyByID(ctx, split[0]) + require.NoError(t, err, "fetch refreshed login key") + // 1 minute tolerance + require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24).Add(time.Minute*-1)), "login key lasts 24 hrs") + }) } func TestPostLogout(t *testing.T) { From dd6b8eeee8141a3bfe9afe8ece6dd0960e6e77da Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 31 May 2022 18:52:40 -0500 Subject: [PATCH 4/5] Better defaults --- coderd/users.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/coderd/users.go b/coderd/users.go index cebc08a9668c2..1ffbd554453a5 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -726,8 +726,13 @@ func (api *API) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat } hashed := sha256.Sum256([]byte(keySecret)) + // Default expires at to now+lifetime, or just 24hrs if not set if params.ExpiresAt.IsZero() { - params.ExpiresAt = database.Now().Add(24 * time.Hour) + if params.LifetimeSeconds != 0 { + params.ExpiresAt = database.Now().Add(time.Duration(params.LifetimeSeconds) * time.Second) + } else { + params.ExpiresAt = database.Now().Add(24 * time.Hour) + } } _, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ From 687d6e2fc5b2891966523afad2d34d69c11b05a2 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 31 May 2022 18:53:02 -0500 Subject: [PATCH 5/5] Import order --- coderd/users_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/users_test.go b/coderd/users_test.go index f975665f4e94f..2ebc4d07d5fe8 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -10,12 +10,11 @@ import ( "testing" "time" - "github.com/coder/coder/coderd/database" - "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/databasefake" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac"