diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index bb9f65df3cd9c..d3ee0244eb79a 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1382,6 +1382,7 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP ID: arg.ID, LifetimeSeconds: arg.LifetimeSeconds, HashedSecret: arg.HashedSecret, + IPAddress: arg.IPAddress, UserID: arg.UserID, ExpiresAt: arg.ExpiresAt, CreatedAt: arg.CreatedAt, @@ -1802,6 +1803,7 @@ func (q *fakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPI } apiKey.LastUsed = arg.LastUsed apiKey.ExpiresAt = arg.ExpiresAt + apiKey.IPAddress = arg.IPAddress apiKey.OAuthAccessToken = arg.OAuthAccessToken apiKey.OAuthRefreshToken = arg.OAuthRefreshToken apiKey.OAuthExpiry = arg.OAuthExpiry diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 61dae91780ed3..a01d039493517 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -99,7 +99,8 @@ CREATE TABLE api_keys ( 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, - lifetime_seconds bigint DEFAULT 86400 NOT NULL + lifetime_seconds bigint DEFAULT 86400 NOT NULL, + ip_address inet DEFAULT '0.0.0.0'::inet NOT NULL ); CREATE TABLE audit_logs ( diff --git a/coderd/database/migrations/000027_apikey_ip.down.sql b/coderd/database/migrations/000027_apikey_ip.down.sql new file mode 100644 index 0000000000000..4196f32dc90a2 --- /dev/null +++ b/coderd/database/migrations/000027_apikey_ip.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE ONLY api_keys + DROP COLUMN IF EXISTS ip_address; diff --git a/coderd/database/migrations/000027_apikey_ip.up.sql b/coderd/database/migrations/000027_apikey_ip.up.sql new file mode 100644 index 0000000000000..b984487be8cea --- /dev/null +++ b/coderd/database/migrations/000027_apikey_ip.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE ONLY api_keys + ADD COLUMN IF NOT EXISTS ip_address inet NOT NULL DEFAULT '0.0.0.0'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 7b4ac76016671..0bce9017608fc 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -311,19 +311,20 @@ func (e *WorkspaceTransition) Scan(src interface{}) error { } type APIKey struct { - ID string `db:"id" json:"id"` - 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"` - 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"` - LoginType LoginType `db:"login_type" json:"login_type"` - OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` - 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"` + ID string `db:"id" json:"id"` + 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"` + 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"` + LoginType LoginType `db:"login_type" json:"login_type"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + 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"` + IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"` } type AuditLog struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b45f0188607d1..80ffa077bfcd6 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, lifetime_seconds + 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, ip_address FROM api_keys WHERE @@ -56,12 +56,13 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro &i.OAuthIDToken, &i.OAuthExpiry, &i.LifetimeSeconds, + &i.IPAddress, ) return i, err } const getAPIKeysLastUsedAfter = `-- name: GetAPIKeysLastUsedAfter :many -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, lifetime_seconds FROM api_keys WHERE last_used > $1 +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, lifetime_seconds, ip_address FROM api_keys WHERE last_used > $1 ` func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) { @@ -87,6 +88,7 @@ func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time. &i.OAuthIDToken, &i.OAuthExpiry, &i.LifetimeSeconds, + &i.IPAddress, ); err != nil { return nil, err } @@ -107,6 +109,7 @@ INSERT INTO id, lifetime_seconds, hashed_secret, + ip_address, user_id, last_used, expires_at, @@ -125,23 +128,24 @@ VALUES 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 + , $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) 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, ip_address ` 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"` - 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"` - LoginType LoginType `db:"login_type" json:"login_type"` - OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` - 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"` + ID string `db:"id" json:"id"` + LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` + HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` + IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + LastUsed time.Time `db:"last_used" json:"last_used"` + 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"` + LoginType LoginType `db:"login_type" json:"login_type"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + 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"` } func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) { @@ -149,6 +153,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( arg.ID, arg.LifetimeSeconds, arg.HashedSecret, + arg.IPAddress, arg.UserID, arg.LastUsed, arg.ExpiresAt, @@ -175,6 +180,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( &i.OAuthIDToken, &i.OAuthExpiry, &i.LifetimeSeconds, + &i.IPAddress, ) return i, err } @@ -185,20 +191,22 @@ UPDATE SET last_used = $2, expires_at = $3, - oauth_access_token = $4, - oauth_refresh_token = $5, - oauth_expiry = $6 + ip_address = $4, + oauth_access_token = $5, + oauth_refresh_token = $6, + oauth_expiry = $7 WHERE id = $1 ` type UpdateAPIKeyByIDParams struct { - ID string `db:"id" json:"id"` - LastUsed time.Time `db:"last_used" json:"last_used"` - ExpiresAt time.Time `db:"expires_at" json:"expires_at"` - OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` - OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` - OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` + ID string `db:"id" json:"id"` + LastUsed time.Time `db:"last_used" json:"last_used"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` } func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error { @@ -206,6 +214,7 @@ func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDP arg.ID, arg.LastUsed, arg.ExpiresAt, + arg.IPAddress, arg.OAuthAccessToken, arg.OAuthRefreshToken, arg.OAuthExpiry, diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index c24f779e1d08a..692ac3e69c8a8 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -17,6 +17,7 @@ INSERT INTO id, lifetime_seconds, hashed_secret, + ip_address, user_id, last_used, expires_at, @@ -35,7 +36,7 @@ VALUES 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 *; + , @hashed_secret, @ip_address, @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 @@ -43,9 +44,10 @@ UPDATE SET last_used = $2, expires_at = $3, - oauth_access_token = $4, - oauth_refresh_token = $5, - oauth_expiry = $6 + ip_address = $4, + oauth_access_token = $5, + oauth_refresh_token = $6, + oauth_expiry = $7 WHERE id = $1; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 290a30faa551b..444dcce74682f 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -29,3 +29,4 @@ rename: userstatus: UserStatus gitsshkey: GitSSHKey rbac_roles: RBACRoles + ip_address: IPAddress diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index acbcc9dee2619..704fa5a3a4da9 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -7,12 +7,15 @@ import ( "database/sql" "errors" "fmt" + "net" "net/http" "strings" "time" "golang.org/x/oauth2" + "github.com/tabbed/pqtype" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" ) @@ -164,6 +167,17 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h // Only update LastUsed once an hour to prevent database spam. if now.Sub(key.LastUsed) > time.Hour { key.LastUsed = now + remoteIP := net.ParseIP(r.RemoteAddr) + if remoteIP == nil { + remoteIP = net.IPv4(0, 0, 0, 0) + } + key.IPAddress = pqtype.Inet{ + IPNet: net.IPNet{ + IP: remoteIP, + Mask: remoteIP.DefaultMask(), + }, + Valid: true, + } changed = true } // Only update the ExpiresAt once an hour to prevent database spam. @@ -178,6 +192,7 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h ID: key.ID, LastUsed: key.LastUsed, ExpiresAt: key.ExpiresAt, + IPAddress: key.IPAddress, OAuthAccessToken: key.OAuthAccessToken, OAuthRefreshToken: key.OAuthRefreshToken, OAuthExpiry: key.OAuthExpiry, diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index 7aa57e2d76f62..2e7c1272961c9 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -402,6 +402,41 @@ func TestAPIKey(t *testing.T) { require.Equal(t, token.Expiry, gotAPIKey.ExpiresAt) require.Equal(t, token.AccessToken, gotAPIKey.OAuthAccessToken) }) + + t.Run("RemoteIPUpdates", func(t *testing.T) { + t.Parallel() + var ( + db = databasefake.New() + id, secret = randomAPIKeyParts() + hashed = sha256.Sum256([]byte(secret)) + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() + user = createUser(r.Context(), t, db) + ) + r.RemoteAddr = "1.1.1.1" + r.AddCookie(&http.Cookie{ + Name: httpmw.SessionTokenKey, + Value: fmt.Sprintf("%s-%s", id, secret), + }) + + sentAPIKey, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + ID: id, + HashedSecret: hashed[:], + LastUsed: database.Now().AddDate(0, 0, -1), + ExpiresAt: database.Now().AddDate(0, 0, 1), + UserID: user.ID, + }) + require.NoError(t, err) + httpmw.ExtractAPIKey(db, nil)(successHandler).ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + gotAPIKey, err := db.GetAPIKeyByID(r.Context(), id) + require.NoError(t, err) + + require.NotEqual(t, sentAPIKey.IPAddress, gotAPIKey.IPAddress) + }) } func createUser(ctx context.Context, t *testing.T, db database.Store) database.User { diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 0506eac387732..9a5e7a494bbfd 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "net/url" "runtime" @@ -428,13 +429,17 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { // ConvertAPIKey anonymizes an API key. func ConvertAPIKey(apiKey database.APIKey) APIKey { - return APIKey{ + a := APIKey{ ID: apiKey.ID, UserID: apiKey.UserID, CreatedAt: apiKey.CreatedAt, LastUsed: apiKey.LastUsed, LoginType: apiKey.LoginType, } + if apiKey.IPAddress.Valid { + a.IPAddress = apiKey.IPAddress.IPNet.IP + } + return a } // ConvertWorkspace anonymizes a workspace. @@ -616,6 +621,7 @@ type APIKey struct { CreatedAt time.Time `json:"created_at"` LastUsed time.Time `json:"last_used"` LoginType database.LoginType `json:"login_type"` + IPAddress net.IP `json:"ip_address"` } type User struct { diff --git a/coderd/users.go b/coderd/users.go index aa7d5e549ead2..a38e3db3d2974 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + "net" "net/http" "strings" "time" @@ -13,6 +14,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/google/uuid" + "github.com/tabbed/pqtype" "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" @@ -798,10 +800,21 @@ func (api *API) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat } } - _, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + ip := net.ParseIP(r.RemoteAddr) + if ip == nil { + ip = net.IPv4(0, 0, 0, 0) + } + key, err := api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ ID: keyID, UserID: params.UserID, LifetimeSeconds: params.LifetimeSeconds, + IPAddress: pqtype.Inet{ + IPNet: net.IPNet{ + IP: ip, + Mask: ip.DefaultMask(), + }, + Valid: true, + }, // Make sure in UTC time for common time zone ExpiresAt: params.ExpiresAt.UTC(), CreatedAt: database.Now(), @@ -821,6 +834,10 @@ func (api *API) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat return "", false } + 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) http.SetCookie(rw, &http.Cookie{ diff --git a/coderd/users_test.go b/coderd/users_test.go index c1ec00fd97378..2430ffdd1cd4a 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -175,8 +175,9 @@ func TestPostLogin(t *testing.T) { require.NoError(t, err, "fetch api key") err = api.Database.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{ - ID: apiKey.ID, - LastUsed: apiKey.LastUsed, + ID: apiKey.ID, + LastUsed: apiKey.LastUsed, + IPAddress: apiKey.IPAddress, // This should cause a refresh ExpiresAt: apiKey.ExpiresAt.Add(time.Hour * -2), OAuthAccessToken: apiKey.OAuthAccessToken, @@ -207,8 +208,9 @@ func TestPostLogin(t *testing.T) { require.NoError(t, err, "fetch login key") err = api.Database.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{ - ID: apiKey.ID, - LastUsed: apiKey.LastUsed, + ID: apiKey.ID, + LastUsed: apiKey.LastUsed, + IPAddress: apiKey.IPAddress, // This should cause a refresh ExpiresAt: apiKey.ExpiresAt.Add(time.Hour * -2), OAuthAccessToken: apiKey.OAuthAccessToken,