diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index aad1b333a5274..6bf87ed3718eb 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1126,9 +1126,14 @@ 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, + 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..c87f688d5f46e 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 } @@ -63,6 +64,7 @@ const insertAPIKey = `-- name: InsertAPIKey :one INSERT INTO api_keys ( id, + lifetime_seconds, hashed_secret, user_id, last_used, @@ -76,11 +78,18 @@ 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, + -- 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"` @@ -97,6 +106,7 @@ 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, @@ -123,6 +133,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..9e7cc8e252b7d 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -12,6 +12,7 @@ LIMIT INSERT INTO api_keys ( id, + lifetime_seconds, hashed_secret, user_id, last_used, @@ -25,7 +26,13 @@ INSERT INTO oauth_expiry ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) 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 1e4098c0be431..e001abeebc80c 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -153,7 +153,7 @@ 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 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 7769a67d53fdc..1ffbd554453a5 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -658,9 +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(lifeTime), + LifetimeSeconds: int64(lifeTime.Seconds()), }) if !created { return @@ -721,10 +726,21 @@ 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() { + 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{ - ID: keyID, - UserID: params.UserID, - ExpiresAt: database.Now().Add(24 * time.Hour), + ID: keyID, + UserID: params.UserID, + LifetimeSeconds: params.LifetimeSeconds, + // Make sure in UTC time for common time zone + ExpiresAt: params.ExpiresAt.UTC(), CreatedAt: database.Now(), UpdatedAt: database.Now(), HashedSecret: hashed[:], diff --git a/coderd/users_test.go b/coderd/users_test.go index 66ddb7ce6b771..2ebc4d07d5fe8 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -8,11 +8,13 @@ import ( "sort" "strings" "testing" + "time" "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" @@ -130,6 +132,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) {