diff --git a/cli/prepare.go b/cli/prepare.go index d8cc300ee6..f46d78d2e9 100644 --- a/cli/prepare.go +++ b/cli/prepare.go @@ -110,7 +110,7 @@ To setup kolide infrastructure, use one of the available commands. Enabled: &enabled, Admin: &isAdmin, } - svc, err := service.NewService(ds, pubsub.NewInmemQueryResults(), kitlog.NewNopLogger(), config, nil, clock.C, nil) + svc, err := service.NewService(ds, pubsub.NewInmemQueryResults(), kitlog.NewNopLogger(), config, nil, clock.C, nil, nil) if err != nil { initFatal(err, "creating service") } diff --git a/cli/serve.go b/cli/serve.go index a8bb6c6b72..d9e7e6ddaa 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "fmt" + "io/ioutil" "net/http" "net/url" "os" @@ -22,6 +23,7 @@ import ( "github.com/kolide/kolide/server/mail" "github.com/kolide/kolide/server/pubsub" "github.com/kolide/kolide/server/service" + "github.com/kolide/kolide/server/sso" "github.com/kolide/kolide/server/version" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -136,8 +138,9 @@ the way that the kolide server works. var resultStore kolide.QueryResultStore redisPool := pubsub.NewRedisPool(config.Redis.Address, config.Redis.Password) resultStore = pubsub.NewRedisQueryResults(redisPool) + ssoSessionStore := sso.NewSessionStore(redisPool) - svc, err := service.NewService(ds, resultStore, logger, config, mailService, clock.C, licenseService) + svc, err := service.NewService(ds, resultStore, logger, config, mailService, clock.C, licenseService, ssoSessionStore) if err != nil { initFatal(err, "initializing service") } @@ -204,6 +207,22 @@ the way that the kolide server works. r.Handle("/metrics", prometheus.InstrumentHandler("metrics", promhttp.Handler())) r.Handle("/api/", apiHandler) r.Handle("/", frontendHandler) + if path, ok := os.LookupEnv("KOLIDE_TEST_PAGE_PATH"); ok { + // test that we can load this + _, err := ioutil.ReadFile(path) + if err != nil { + initFatal(err, "loading KOLIDE_TEST_PAGE_PATH") + } + r.HandleFunc("/test", func(rw http.ResponseWriter, req *http.Request) { + testPage, err := ioutil.ReadFile(path) + if err != nil { + rw.WriteHeader(http.StatusNotFound) + return + } + rw.Write(testPage) + rw.WriteHeader(http.StatusOK) + }) + } if debug { // Add debug endpoints with a random diff --git a/docs/third-party/licenses.md b/docs/third-party/licenses.md index f4a941e3ed..7418a708f4 100644 --- a/docs/third-party/licenses.md +++ b/docs/third-party/licenses.md @@ -364,6 +364,7 @@ Third-Party Licenses | [github.com/WatchBeam/clock](https://github.com/WatchBeam/clock) | [MIT](https://opensource.org/licenses/MIT) | | [github.com/alecthomas/template](https://github.com/alecthomas/template) | [NewBSD](https://opensource.org/licenses/BSD-3-Clause) | | [github.com/alecthomas/units](https://github.com/alecthomas/units) | [MIT](https://opensource.org/licenses/MIT) | +| [github.com/beevik/etree](https://github.com/beevik/etree) | [FreeBSD](https://opensource.org/licenses/BSD-2-Clause) | | [github.com/beorn7/perks](https://github.com/beorn7/perks) | [MIT](https://opensource.org/licenses/MIT) | | [github.com/davecgh/go-spew](https://github.com/davecgh/go-spew) | [ISC](https://opensource.org/licenses/ISC) | | [github.com/dgrijalva/jwt-go](https://github.com/dgrijalva/jwt-go) | [MIT](https://opensource.org/licenses/MIT) | @@ -385,6 +386,7 @@ Third-Party Licenses | [github.com/igm/sockjs-go](https://github.com/igm/sockjs-go) | [NewBSD](https://opensource.org/licenses/BSD-3-Clause) | | [github.com/inconshreveable/mousetrap](https://github.com/inconshreveable/mousetrap) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/jmoiron/sqlx](https://github.com/jmoiron/sqlx) | [MIT](https://opensource.org/licenses/MIT) | +| [github.com/jonboulle/clockwork](https://github.com/jonboulle/clockwork) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/jordan-wright/email](https://github.com/jordan-wright/email) | [MIT](https://opensource.org/licenses/MIT) | | [github.com/kolide/goose](https://github.com/kolide/goose) | [MIT](https://opensource.org/licenses/MIT) | | [github.com/kr/logfmt](https://github.com/kr/logfmt) | [MIT](https://opensource.org/licenses/MIT) | @@ -400,6 +402,8 @@ Third-Party Licenses | [github.com/prometheus/client_model](https://github.com/prometheus/client_model) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/prometheus/common](https://github.com/prometheus/common) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/prometheus/procfs](https://github.com/prometheus/procfs) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | +| [github.com/russellhaering/gosaml2](https://github.com/russellhaering/gosaml2) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | +| [github.com/russellhaering/goxmldsig](https://github.com/russellhaering/goxmldsig) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/ryanuber/go-license](https://github.com/ryanuber/go-license) | [MIT](https://opensource.org/licenses/MIT) | | [github.com/spf13/afero](https://github.com/spf13/afero) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/spf13/cast](https://github.com/spf13/cast) | [MIT](https://opensource.org/licenses/MIT) | diff --git a/glide.lock b/glide.lock index 154d637c32..f07b0b7a91 100644 --- a/glide.lock +++ b/glide.lock @@ -1,5 +1,5 @@ -hash: 1baa8047c7c6da3ca484e0bfebd881a17181a76ccccbddb2cff087c3f6de97c9 -updated: 2017-04-05T15:21:30.066640809-07:00 +hash: 7526b737524d134e1aa305fdae1f05ee92ff88c9d5b884b04d0aefcdb5ead7f6 +updated: 2017-05-02T11:10:06.237517785-05:00 imports: - name: github.com/alecthomas/template version: a0175ee3bccc567396460bf5acd36800cb10c49c @@ -7,6 +7,8 @@ imports: - parse - name: github.com/alecthomas/units version: 2efee857e7cfd4f3d0138cc3cbb1b4966962b93a +- name: github.com/beevik/etree + version: cda1c0026246bd095961ef9a3c430e50d0e80fba - name: github.com/beorn7/perks version: 4c0e84591b9aa9e6dcfdf3e020114cd81f89d5f9 subpackages: @@ -78,6 +80,8 @@ imports: version: 5f97679e23f75f42b265fec8d3bdb1c8de90b79d subpackages: - reflectx +- name: github.com/jonboulle/clockwork + version: bcac9884e7502bb2b474c0339d889cb981a2f27f - name: github.com/jordan-wright/email version: fd703108daeb23d77c124d12978e9b6c4f28f034 - name: github.com/kolide/goose @@ -121,6 +125,15 @@ imports: - model - name: github.com/prometheus/procfs version: 1878d9fbb537119d24b21ca07effd591627cd160 +- name: github.com/russellhaering/gosaml2 + version: 6074095354a9bead88f041558b2150ce47676371 + subpackages: + - types +- name: github.com/russellhaering/goxmldsig + version: eaac44c63fe007124f8f6255b09febc906784981 + subpackages: + - etreeutils + - types - name: github.com/ryanuber/go-license version: 027a317812857d68bb611443b5c08bbe96e88598 - name: github.com/spf13/afero @@ -166,7 +179,7 @@ imports: - transform - unicode/norm - name: gopkg.in/alecthomas/kingpin.v2 - version: e9044be3ab2a8e11d4e1f418d12f0790d57e8d70 + version: 7f0871f2e17818990e4eed73f9b5c2f429501228 - name: gopkg.in/go-playground/validator.v8 version: 5f57d2222ad794d0dffb07e664ea05e2ee07d60c - name: gopkg.in/natefinch/lumberjack.v2 diff --git a/glide.yaml b/glide.yaml index 242acacbd1..4c7d2cee38 100644 --- a/glide.yaml +++ b/glide.yaml @@ -59,7 +59,7 @@ import: - redis - package: github.com/jmoiron/sqlx - package: github.com/kolide/goose - version: master + version: 4a7848793d4402d338de853019ad72b9a4b3e68e - package: github.com/VividCortex/mysqlerr version: master - package: github.com/go-kit/kit @@ -70,3 +70,4 @@ import: - package: github.com/e-dard/netbug - package: github.com/spf13/cast version: ~1.0.0 +- package: github.com/russellhaering/gosaml2 diff --git a/server/datastore/datastore_identity_providers_test.go b/server/datastore/datastore_identity_providers_test.go deleted file mode 100644 index 572b70f1af..0000000000 --- a/server/datastore/datastore_identity_providers_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package datastore - -import ( - "testing" - - "github.com/kolide/kolide/server/kolide" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func testIdentityProvider(t *testing.T, ds kolide.Datastore) { - if ds.Name() == "inmem" { - t.Skip("imem is being deprecated") - } - idps := []*kolide.IdentityProvider{ - &kolide.IdentityProvider{ - SingleSignOnURL: "https://idp1.com/sso", - IssuerURI: "http://idp1.com/issuer/xyz123", - Certificate: "DEADBEEFXXXXX12344", - Name: "idp1", - ImageURL: "https://idp1.com/logo.png", - }, - &kolide.IdentityProvider{ - SingleSignOnURL: "https://idp2.com/sso", - IssuerURI: "http://idp2.com/issuer/xyz123", - Certificate: "DEADBEEFXXXXX12344", - Name: "idp2", - ImageURL: "https://idp2.com/logo.png", - }, - &kolide.IdentityProvider{ - SingleSignOnURL: "https://idp3.com/sso", - IssuerURI: "http://idp3.com/issuer/xyz123", - Certificate: "DEADBEEFXXXXX12344", - Name: "idp3", - ImageURL: "https://idp3.com/logo.png", - }, - } - var err error - for i, idp := range idps { - idps[i], err = ds.NewIdentityProvider(*idp) - require.Nil(t, err) - require.NotEqual(t, 0, idp.ID, "id assignment") - } - // duplicate name not allowed - _, err = ds.NewIdentityProvider(*idps[0]) - assert.NotNil(t, err) - // test get - idp, err := ds.IdentityProvider(idps[0].ID) - require.Nil(t, err) - require.NotNil(t, idp) - require.Equal(t, "idp1", idp.Name) - // test update - idp.ImageURL = "https://idpnew.com/logo.png" - idp.SingleSignOnURL = "https://idpnew.com/sso" - idp.IssuerURI = "https://idpnew.com/issuer" - idp.Certificate = "123456789" - idp.Name = "idpnew" - err = ds.SaveIdentityProvider(*idp) - require.Nil(t, err) - upd, err := ds.IdentityProvider(idp.ID) - require.Nil(t, err) - require.NotNil(t, upd) - assert.Equal(t, idp.ImageURL, upd.ImageURL) - assert.Equal(t, idp.SingleSignOnURL, upd.SingleSignOnURL) - assert.Equal(t, idp.IssuerURI, upd.IssuerURI) - assert.Equal(t, idp.Certificate, upd.Certificate) - assert.Equal(t, idp.Name, upd.Name) - // test list - results, err := ds.ListIdentityProviders() - require.Nil(t, err) - require.NotNil(t, results) - assert.Len(t, results, 3) - // test delete - err = ds.DeleteIdentityProvider(results[0].ID) - assert.Nil(t, err) - err = ds.DeleteIdentityProvider(results[0].ID) - assert.NotNil(t, err) - results, err = ds.ListIdentityProviders() - require.Nil(t, err) - assert.NotNil(t, results, 2) -} diff --git a/server/datastore/datastore_test.go b/server/datastore/datastore_test.go index beb5501733..040d2196d7 100644 --- a/server/datastore/datastore_test.go +++ b/server/datastore/datastore_test.go @@ -80,5 +80,4 @@ var testFunctions = [...]func(*testing.T, kolide.Datastore){ testCountHostsInTargets, testHostStatus, testResetOptions, - testIdentityProvider, } diff --git a/server/datastore/inmem/identity_providers.go b/server/datastore/inmem/identity_providers.go deleted file mode 100644 index 04b4a24239..0000000000 --- a/server/datastore/inmem/identity_providers.go +++ /dev/null @@ -1,25 +0,0 @@ -package inmem - -import ( - "github.com/kolide/kolide/server/kolide" -) - -func (d *Datastore) NewIdentityProvider(idp kolide.IdentityProvider) (*kolide.IdentityProvider, error) { - panic("inmem is being deprecated") -} - -func (d *Datastore) SaveIdentityProvider(idb kolide.IdentityProvider) error { - panic("inmem is being deprecated") -} - -func (d *Datastore) IdentityProvider(id uint) (*kolide.IdentityProvider, error) { - panic("inmem is being deprecated") -} - -func (d *Datastore) DeleteIdentityProvider(id uint) error { - panic("inmem is being deprecated") -} - -func (d *Datastore) ListIdentityProviders() ([]kolide.IdentityProvider, error) { - panic("inmem is being deprecated") -} diff --git a/server/datastore/mysql/app_configs.go b/server/datastore/mysql/app_configs.go index 3badcce7f3..68cd36cf35 100644 --- a/server/datastore/mysql/app_configs.go +++ b/server/datastore/mysql/app_configs.go @@ -28,44 +28,58 @@ func (d *Datastore) SaveAppConfig(info *kolide.AppConfig) error { // exist, a row will be created with INSERT, if a row does exist the key // will be violate uniqueness constraint and an UPDATE will occur insertStatement := ` - INSERT INTO app_configs ( - id, - org_name, - org_logo_url, - kolide_server_url, - osquery_enroll_secret, - smtp_configured, - smtp_sender_address, - smtp_server, - smtp_port, - smtp_authentication_type, - smtp_enable_ssl_tls, - smtp_authentication_method, - smtp_domain, - smtp_user_name, - smtp_password, - smtp_verify_ssl_certs, - smtp_enable_start_tls - ) - VALUES( 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) - ON DUPLICATE KEY UPDATE - org_name = VALUES(org_name), - org_logo_url = VALUES(org_logo_url), - kolide_server_url = VALUES(kolide_server_url), - osquery_enroll_secret = VALUES(osquery_enroll_secret), - smtp_configured = VALUES(smtp_configured), - smtp_sender_address = VALUES(smtp_sender_address), - smtp_server = VALUES(smtp_server), - smtp_port = VALUES(smtp_port), - smtp_authentication_type = VALUES(smtp_authentication_type), - smtp_enable_ssl_tls = VALUES(smtp_enable_ssl_tls), - smtp_authentication_method = VALUES(smtp_authentication_method), - smtp_domain = VALUES(smtp_domain), - smtp_user_name = VALUES(smtp_user_name), - smtp_password = VALUES(smtp_password), - smtp_verify_ssl_certs = VALUES(smtp_verify_ssl_certs), - smtp_enable_start_tls = VALUES(smtp_enable_start_tls) - ` + INSERT INTO app_configs ( + id, + org_name, + org_logo_url, + kolide_server_url, + osquery_enroll_secret, + smtp_configured, + smtp_sender_address, + smtp_server, + smtp_port, + smtp_authentication_type, + smtp_enable_ssl_tls, + smtp_authentication_method, + smtp_domain, + smtp_user_name, + smtp_password, + smtp_verify_ssl_certs, + smtp_enable_start_tls, + entity_id, + issuer_uri, + idp_image_url, + metadata, + metadata_url, + idp_name, + enable_sso + ) + VALUES( 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) + ON DUPLICATE KEY UPDATE + org_name = VALUES(org_name), + org_logo_url = VALUES(org_logo_url), + kolide_server_url = VALUES(kolide_server_url), + osquery_enroll_secret = VALUES(osquery_enroll_secret), + smtp_configured = VALUES(smtp_configured), + smtp_sender_address = VALUES(smtp_sender_address), + smtp_server = VALUES(smtp_server), + smtp_port = VALUES(smtp_port), + smtp_authentication_type = VALUES(smtp_authentication_type), + smtp_enable_ssl_tls = VALUES(smtp_enable_ssl_tls), + smtp_authentication_method = VALUES(smtp_authentication_method), + smtp_domain = VALUES(smtp_domain), + smtp_user_name = VALUES(smtp_user_name), + smtp_password = VALUES(smtp_password), + smtp_verify_ssl_certs = VALUES(smtp_verify_ssl_certs), + smtp_enable_start_tls = VALUES(smtp_enable_start_tls), + entity_id = VALUES(entity_id), + issuer_uri = VALUES(issuer_uri), + idp_image_url = VALUES(idp_image_url), + metadata = VALUES(metadata), + metadata_url = VALUES(metadata_url), + idp_name = VALUES(idp_name), + enable_sso = VALUES(enable_sso) + ` _, err := d.db.Exec(insertStatement, info.OrgName, @@ -84,6 +98,13 @@ func (d *Datastore) SaveAppConfig(info *kolide.AppConfig) error { info.SMTPPassword, info.SMTPVerifySSLCerts, info.SMTPEnableStartTLS, + info.EntityID, + info.IssuerURI, + info.IDPImageURL, + info.Metadata, + info.MetadataURL, + info.IDPName, + info.EnableSSO, ) return err diff --git a/server/datastore/mysql/identity_providers.go b/server/datastore/mysql/identity_providers.go deleted file mode 100644 index 7120c09505..0000000000 --- a/server/datastore/mysql/identity_providers.go +++ /dev/null @@ -1,91 +0,0 @@ -package mysql - -import ( - "database/sql" - - "github.com/kolide/kolide/server/kolide" - "github.com/pkg/errors" -) - -func (d *Datastore) NewIdentityProvider(idp kolide.IdentityProvider) (*kolide.IdentityProvider, error) { - query := ` - INSERT INTO identity_providers ( - sso_url, - issuer_uri, - cert, - name, - image_url - ) - VALUES ( ?, ?, ?, ?, ? ) - ` - result, err := d.db.Exec(query, idp.SingleSignOnURL, idp.IssuerURI, idp.Certificate, idp.Name, idp.ImageURL) - if err != nil { - return nil, errors.Wrap(err, "creating identity provider") - } - id, err := result.LastInsertId() - if err != nil { - return nil, errors.Wrap(err, "retrieving id for new identity provider") - } - idp.ID = uint(id) - return &idp, nil -} - -func (d *Datastore) SaveIdentityProvider(idp kolide.IdentityProvider) error { - query := ` - UPDATE identity_providers - SET - sso_url = ?, - issuer_uri = ?, - cert = ?, - name = ?, - image_url = ? - WHERE id = ? - ` - result, err := d.db.Exec(query, idp.SingleSignOnURL, idp.IssuerURI, idp.Certificate, - idp.Name, idp.ImageURL, idp.ID) - if err != nil { - return errors.Wrap(err, "updating identity provider") - } - rows, err := result.RowsAffected() - if err != nil { - return errors.Wrap(err, "fetching updated row count for identity provider") - } - if rows == 0 { - return notFound("IdentityProvider").WithID(idp.ID) - } - return nil -} - -func (d *Datastore) IdentityProvider(id uint) (*kolide.IdentityProvider, error) { - query := ` - SELECT * - FROM identity_providers - WHERE id = ? AND NOT deleted - ` - var idp kolide.IdentityProvider - err := d.db.Get(&idp, query, id) - if err == sql.ErrNoRows { - return nil, notFound("IdentityProvider").WithID(id) - } - if err != nil { - return nil, errors.Wrap(err, "selecting identity provider") - } - return &idp, nil -} - -func (d *Datastore) DeleteIdentityProvider(id uint) error { - return d.deleteEntity("identity_providers", id) -} - -func (d *Datastore) ListIdentityProviders() ([]kolide.IdentityProvider, error) { - query := ` - SELECT * - FROM identity_providers - WHERE NOT deleted - ` - var idps []kolide.IdentityProvider - if err := d.db.Select(&idps, query); err != nil { - return nil, errors.Wrap(err, "listing identity providers") - } - return idps, nil -} diff --git a/server/datastore/mysql/migrations/tables/20170411155225_CreateTableIdentityProviders.go b/server/datastore/mysql/migrations/tables/20170411155225_CreateTableIdentityProviders.go deleted file mode 100644 index 9339db33cb..0000000000 --- a/server/datastore/mysql/migrations/tables/20170411155225_CreateTableIdentityProviders.go +++ /dev/null @@ -1,34 +0,0 @@ -package tables - -import ( - "database/sql" -) - -func init() { - MigrationClient.AddMigration(Up_20170411155225, Down_20170411155225) -} - -func Up_20170411155225(tx *sql.Tx) error { - statement := - "CREATE TABLE `identity_providers` ( " + - "`id` int(11) NOT NULL AUTO_INCREMENT, " + - "`sso_url` varchar(1024) NOT NULL DEFAULT '', " + - "`issuer_uri` varchar(1024) NOT NULL DEFAULT '', " + - "`cert` text NOT NULL, " + - "`name` varchar(128) NOT NULL DEFAULT '', " + - "`image_url` varchar(1024) NOT NULL DEFAULT '', " + - "`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, " + - "`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + - "`deleted_at` timestamp NULL DEFAULT NULL, " + - "`deleted` tinyint(1) NOT NULL DEFAULT FALSE, " + - "PRIMARY KEY (`id`), " + - "UNIQUE KEY `idx_unique_identity_providers_name` (`name`) USING BTREE " + - ") ENGINE=InnoDB DEFAULT CHARSET=utf8;" - _, err := tx.Exec(statement) - return err -} - -func Down_20170411155225(tx *sql.Tx) error { - _, err := tx.Exec("DROP TABLE IF EXISTS `identity_providers`;") - return err -} diff --git a/server/datastore/mysql/migrations/tables/20170502143928_AddIDPColsToAppConfig.go b/server/datastore/mysql/migrations/tables/20170502143928_AddIDPColsToAppConfig.go new file mode 100644 index 0000000000..576772e5a1 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20170502143928_AddIDPColsToAppConfig.go @@ -0,0 +1,37 @@ +package tables + +import ( + "database/sql" +) + +func init() { + MigrationClient.AddMigration(Up_20170502143928, Down_20170502143928) +} + +func Up_20170502143928(tx *sql.Tx) error { + _, err := tx.Exec( + "ALTER TABLE `kolide`.`app_configs` " + + "ADD COLUMN `entity_id` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' AFTER `osquery_enroll_secret`, " + + "ADD COLUMN `issuer_uri` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' AFTER `entity_id`, " + + "ADD COLUMN `idp_image_url` VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' AFTER `issuer_uri`, " + + "ADD COLUMN `metadata` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL AFTER `idp_image_url`, " + + "ADD COLUMN `metadata_url` VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' AFTER `metadata`, " + + "ADD COLUMN `idp_name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' AFTER `metadata_url`, " + + "ADD COLUMN `enable_sso` TINYINT(1) NOT NULL DEFAULT FALSE AFTER `idp_name`; ", + ) + return err +} + +func Down_20170502143928(tx *sql.Tx) error { + _, err := tx.Exec( + "ALTER TABLE `kolide`.`app_configs` " + + "DROP COLUMN `entity_id`, " + + "DROP COLUMN `issuer_uri`, " + + "DROP COLUMN `idp_image_url`, " + + "DROP COLUMN `metadata`, " + + "DROP COLUMN `metadata_url`, " + + "DROP COLUMN `idp_name`, " + + "DROP COLUMN `enable_sso`;", + ) + return err +} diff --git a/server/kolide/app.go b/server/kolide/app.go index 7c5655352c..5b3a7b7b36 100644 --- a/server/kolide/app.go +++ b/server/kolide/app.go @@ -112,6 +112,21 @@ type AppConfig struct { SMTPVerifySSLCerts bool `db:"smtp_verify_ssl_certs"` // SMTPEnableStartTLS detects of TLS is enabled on mail server and starts to use it (default true) SMTPEnableStartTLS bool `db:"smtp_enable_start_tls"` + // EntityID is a uri that identifies this service provider + EntityID string `db:"entity_id"` + // IssuerURI is the uri that identifies the identity provider + IssuerURI string `db:"issuer_uri"` + // IDPImageURL is a link to a logo or other image that is used for UX + IDPImageURL string `db:"idp_image_url"` + // Metadata contains IDP metadata XML + Metadata string `db:"metadata"` + // MetadataURL is a URL provided by the IDP which can be used to download + // metadata + MetadataURL string `db:"metadata_url"` + // IDPName is a human freindly name for the IDP + IDPName string `db:"idp_name"` + // EnableSSO flag to determine whether or not to enable SSO + EnableSSO bool `db:"enable_sso"` } // ModifyAppConfigRequest contains application configuration information @@ -124,6 +139,25 @@ type ModifyAppConfigRequest struct { AppConfig AppConfig `json:"app_config"` } +// SSOSettingsPayload wire format for SSO settings +type SSOSettingsPayload struct { + // EntityID is a uri that identifies this service provider + EntityID *string `json:"entity_id"` + // IssuerURI is the uri that identifies the identity provider + IssuerURI *string `json:"issuer_uri"` + // IDPImageURL is a link to a logo or other image that is used for UX + IDPImageURL *string `json:"idp_image_url"` + // Metadata contains IDP metadata XML + Metadata *string `json:"metadata"` + // MetadataURL is a URL provided by the IDP which can be used to download + // metadata + MetadataURL *string `json:"metadata_url"` + // IDPName is a human freindly name for the IDP + IDPName *string `json:"idp_name"` + // EnableSSO flag to determine whether or not to enable SSO + EnableSSO *bool `json:"enable_sso"` +} + // SMTPSettingsPayload is part of the AppConfigPayload which defines the wire representation // of the app config endpoints type SMTPSettingsPayload struct { @@ -165,6 +199,8 @@ type AppConfigPayload struct { SMTPSettings *SMTPSettingsPayload `json:"smtp_settings"` // SMTPTest is a flag that if set will cause the server to test email configuration SMTPTest *bool `json:"smtp_test,omitempty"` + // SSOSettings single sign settings + SSOSettings *SSOSettingsPayload `json:"sso_settings"` } // OrgInfo contains general info about the organization using Kolide. diff --git a/server/kolide/datastore.go b/server/kolide/datastore.go index 791eff87e5..de43e5e70c 100644 --- a/server/kolide/datastore.go +++ b/server/kolide/datastore.go @@ -19,7 +19,6 @@ type Datastore interface { FileIntegrityMonitoringStore YARAStore LicenseStore - IdentityProviderStore Name() string Drop() error // MigrateTables creates and migrates the table schemas diff --git a/server/kolide/identity_providers.go b/server/kolide/identity_providers.go deleted file mode 100644 index 271bac19a6..0000000000 --- a/server/kolide/identity_providers.go +++ /dev/null @@ -1,62 +0,0 @@ -package kolide - -import "context" - -// IdentityProviderStore exposes methods to persist IdentityProviders. -// IdentityProvider is an entity used for single sign on. -type IdentityProviderStore interface { - // NewIdentityProvider creates a new IdentityProvider. - NewIdentityProvider(idp IdentityProvider) (*IdentityProvider, error) - // SaveIdentityProvider saves changes to an IdentityProvider. - SaveIdentityProvider(idb IdentityProvider) error - // IdentityProvider retrieves an IdentityProvider identified by id. - IdentityProvider(id uint) (*IdentityProvider, error) - // DeleteIdentityProvider soft deletes an IdentityProvider - DeleteIdentityProvider(id uint) error - // ListIdentityProviders returns all IdentityProvider entities - ListIdentityProviders() ([]IdentityProvider, error) -} - -// IdentityProvider represents a SAML identity provider. -type IdentityProvider struct { - UpdateCreateTimestamps - DeleteFields - ID uint `json:"id"` - // SingleSignOnURL is the URL for the identity provider. - SingleSignOnURL string `json:"sso_url" db:"sso_url"` - // IssuerURI identity provider issuer - IssuerURI string `json:"issuer_uri" db:"issuer_uri"` - // Certificate is the identity provider's public certificate. - Certificate string `json:"cert" db:"cert"` - // Name is the descriptive name for the identity provider that will - // be displayed in the UI. - Name string `json:"name"` - // ImageURL is a link to an icon that will be displayed on the SSO - // button for a particular identity provider. - ImageURL string `json:"image_url" db:"image_url"` -} - -// IdentityProviderPayload user to update one or more fields of an IdentityProvider -// by supplying values that correspond to fields that will be changed. -type IdentityProviderPayload struct { - SingleSignOnURL *string `json:"sso_url"` - IssuerURI *string `json:"issuer_uri"` - Certificate *string `json:"cert"` - Name *string `json:"name"` - ImageURL *string `json:"image_url"` -} - -// IdentityProviderService exposes methods to manage IdentityProvider entities -type IdentityProviderService interface { - // NewIdentityProvider creates a IdentityProvider - NewIdentityProvider(ctx context.Context, payload IdentityProviderPayload) (*IdentityProvider, error) - // SaveIdentityProvider is used to modify an existing IdentityProvider. Nonnil - // fields in the payload argument will be changed for an existing IdentityProvider - ModifyIdentityProvider(ctx context.Context, id uint, payload IdentityProviderPayload) (*IdentityProvider, error) - // GetIdentityProvider retrieves an IdentityProvider given it's ID. - GetIdentityProvider(ctx context.Context, id uint) (*IdentityProvider, error) - // DeleteIdentityProvider removes an IdentityProvider - DeleteIdentityProvider(ctx context.Context, id uint) error - // ListIdentityProviders returns a list of all IdentityProvider entities - ListIdentityProviders(ctx context.Context, id uint) ([]IdentityProvider, error) -} diff --git a/server/kolide/sessions.go b/server/kolide/sessions.go index 0271d53bbe..c5039153da 100644 --- a/server/kolide/sessions.go +++ b/server/kolide/sessions.go @@ -32,10 +32,21 @@ type SessionStore interface { MarkSessionAccessed(session *Session) error } +type Auth interface { + UserID() string + RequestID() string +} + type SessionService interface { - // SSOLogin handles creating a session for a user who is authenticated by - // a SAML identity provider, returning the user and a token on success - SSOLogin(ctx context.Context, userId string) (*User, string, error) + // InitiateSSO is used to initiate an SSO session and returns a URL that + // can be used in a redirect to the IDP. + // Arguments: redirectURL is the URL of the protected resource that the user + // was trying to access when they were promted to log in. + InitiateSSO(ctx context.Context, redirectURL string) (string, error) + // CallbackSSO handles the IDP response. The original URL the viewer attempted + // to access is returned from this function so we can redirect back to the front end and + // load the page the viewer originally attempted to access when prompted for login. + CallbackSSO(ctx context.Context, auth Auth) (*SSOSession, error) Login(ctx context.Context, username, password string) (user *User, token string, err error) Logout(ctx context.Context) (err error) DestroySession(ctx context.Context) (err error) @@ -46,6 +57,11 @@ type SessionService interface { DeleteSession(ctx context.Context, id uint) (err error) } +type SSOSession struct { + Token string + RedirectURL string +} + // Session is the model object which represents what an active session is type Session struct { CreateTimestamp diff --git a/server/mock/datastore.go b/server/mock/datastore.go index 36b615a188..a5800c9768 100644 --- a/server/mock/datastore.go +++ b/server/mock/datastore.go @@ -9,7 +9,6 @@ package mock //go:generate mockimpl -o datastore_options.go "s *OptionStore" "kolide.OptionStore" //go:generate mockimpl -o datastore_packs.go "s *PackStore" "kolide.PackStore" //go:generate mockimpl -o datastore_hosts.go "s *HostStore" "kolide.HostStore" -//go:generate mockimpl -o datastore_identity_providers.go "s *IdentityProviderStore" "kolide.IdentityProviderStore" import "github.com/kolide/kolide/server/kolide" @@ -33,7 +32,6 @@ type Store struct { OptionStore PackStore UserStore - IdentityProviderStore } func (m *Store) Drop() error { diff --git a/server/mock/datastore_hosts.go b/server/mock/datastore_hosts.go index 8200490b97..20998970ba 100644 --- a/server/mock/datastore_hosts.go +++ b/server/mock/datastore_hosts.go @@ -26,10 +26,10 @@ type AuthenticateHostFunc func(nodeKey string) (*kolide.Host, error) type MarkHostSeenFunc func(host *kolide.Host, t time.Time) error -type GenerateHostStatusStatisticsFunc func(now time.Time) (online uint, offline uint, mia uint, new uint, err error) - type SearchHostsFunc func(query string, omit ...uint) ([]*kolide.Host, error) +type GenerateHostStatusStatisticsFunc func(now time.Time) (online uint, offline uint, mia uint, new uint, err error) + type DistributedQueriesForHostFunc func(host *kolide.Host) (map[uint]string, error) type HostStore struct { @@ -57,12 +57,12 @@ type HostStore struct { MarkHostSeenFunc MarkHostSeenFunc MarkHostSeenFuncInvoked bool - GenerateHostStatusStatisticsFunc GenerateHostStatusStatisticsFunc - GenerateHostStatusStatisticsFuncInvoked bool - SearchHostsFunc SearchHostsFunc SearchHostsFuncInvoked bool + GenerateHostStatusStatisticsFunc GenerateHostStatusStatisticsFunc + GenerateHostStatusStatisticsFuncInvoked bool + DistributedQueriesForHostFunc DistributedQueriesForHostFunc DistributedQueriesForHostFuncInvoked bool } @@ -107,16 +107,16 @@ func (s *HostStore) MarkHostSeen(host *kolide.Host, t time.Time) error { return s.MarkHostSeenFunc(host, t) } -func (s *HostStore) GenerateHostStatusStatistics(now time.Time) (online uint, offline uint, mia uint, new uint, err error) { - s.GenerateHostStatusStatisticsFuncInvoked = true - return s.GenerateHostStatusStatisticsFunc(now) -} - func (s *HostStore) SearchHosts(query string, omit ...uint) ([]*kolide.Host, error) { s.SearchHostsFuncInvoked = true return s.SearchHostsFunc(query, omit...) } +func (s *HostStore) GenerateHostStatusStatistics(now time.Time) (online uint, offline uint, mia uint, new uint, err error) { + s.GenerateHostStatusStatisticsFuncInvoked = true + return s.GenerateHostStatusStatisticsFunc(now) +} + func (s *HostStore) DistributedQueriesForHost(host *kolide.Host) (map[uint]string, error) { s.DistributedQueriesForHostFuncInvoked = true return s.DistributedQueriesForHostFunc(host) diff --git a/server/mock/datastore_identity_providers.go b/server/mock/datastore_identity_providers.go deleted file mode 100644 index f906afcb1b..0000000000 --- a/server/mock/datastore_identity_providers.go +++ /dev/null @@ -1,59 +0,0 @@ -// Automatically generated by mockimpl. DO NOT EDIT! - -package mock - -import "github.com/kolide/kolide/server/kolide" - -var _ kolide.IdentityProviderStore = (*IdentityProviderStore)(nil) - -type NewIdentityProviderFunc func(idp kolide.IdentityProvider) (*kolide.IdentityProvider, error) - -type SaveIdentityProviderFunc func(idb kolide.IdentityProvider) error - -type IdentityProviderFunc func(id uint) (*kolide.IdentityProvider, error) - -type DeleteIdentityProviderFunc func(id uint) error - -type ListIdentityProvidersFunc func() ([]kolide.IdentityProvider, error) - -type IdentityProviderStore struct { - NewIdentityProviderFunc NewIdentityProviderFunc - NewIdentityProviderFuncInvoked bool - - SaveIdentityProviderFunc SaveIdentityProviderFunc - SaveIdentityProviderFuncInvoked bool - - IdentityProviderFunc IdentityProviderFunc - IdentityProviderFuncInvoked bool - - DeleteIdentityProviderFunc DeleteIdentityProviderFunc - DeleteIdentityProviderFuncInvoked bool - - ListIdentityProvidersFunc ListIdentityProvidersFunc - ListIdentityProvidersFuncInvoked bool -} - -func (s *IdentityProviderStore) NewIdentityProvider(idp kolide.IdentityProvider) (*kolide.IdentityProvider, error) { - s.NewIdentityProviderFuncInvoked = true - return s.NewIdentityProviderFunc(idp) -} - -func (s *IdentityProviderStore) SaveIdentityProvider(idb kolide.IdentityProvider) error { - s.SaveIdentityProviderFuncInvoked = true - return s.SaveIdentityProviderFunc(idb) -} - -func (s *IdentityProviderStore) IdentityProvider(id uint) (*kolide.IdentityProvider, error) { - s.IdentityProviderFuncInvoked = true - return s.IdentityProviderFunc(id) -} - -func (s *IdentityProviderStore) DeleteIdentityProvider(id uint) error { - s.DeleteIdentityProviderFuncInvoked = true - return s.DeleteIdentityProviderFunc(id) -} - -func (s *IdentityProviderStore) ListIdentityProviders() ([]kolide.IdentityProvider, error) { - s.ListIdentityProvidersFuncInvoked = true - return s.ListIdentityProvidersFunc() -} diff --git a/server/service/endpoint_appconfig.go b/server/service/endpoint_appconfig.go index 5463e63f6b..ef505d7bc3 100644 --- a/server/service/endpoint_appconfig.go +++ b/server/service/endpoint_appconfig.go @@ -17,6 +17,7 @@ type appConfigResponse struct { OrgInfo *kolide.OrgInfo `json:"org_info,omitemtpy"` ServerSettings *kolide.ServerSettings `json:"server_settings,omitempty"` SMTPSettings *kolide.SMTPSettingsPayload `json:"smtp_settings,omitempty"` + SSOSettings *kolide.SSOSettingsPayload `json:"sso_settings,omitempty"` Err error `json:"error,omitempty"` } @@ -33,12 +34,22 @@ func makeGetAppConfigEndpoint(svc kolide.Service) endpoint.Endpoint { return nil, err } var smtpSettings *kolide.SMTPSettingsPayload + var ssoSettings *kolide.SSOSettingsPayload // only admin can see smtp settings if vc.IsAdmin() { smtpSettings = smtpSettingsFromAppConfig(config) if smtpSettings.SMTPPassword != nil { *smtpSettings.SMTPPassword = "********" } + ssoSettings = &kolide.SSOSettingsPayload{ + EntityID: &config.EntityID, + IssuerURI: &config.IssuerURI, + IDPImageURL: &config.IDPImageURL, + Metadata: &config.Metadata, + MetadataURL: &config.MetadataURL, + IDPName: &config.IDPName, + EnableSSO: &config.EnableSSO, + } } response := appConfigResponse{ OrgInfo: &kolide.OrgInfo{ @@ -50,6 +61,7 @@ func makeGetAppConfigEndpoint(svc kolide.Service) endpoint.Endpoint { EnrollSecret: &config.EnrollSecret, }, SMTPSettings: smtpSettings, + SSOSettings: ssoSettings, } return response, nil } @@ -72,6 +84,15 @@ func makeModifyAppConfigEndpoint(svc kolide.Service) endpoint.Endpoint { EnrollSecret: &config.EnrollSecret, }, SMTPSettings: smtpSettingsFromAppConfig(config), + SSOSettings: &kolide.SSOSettingsPayload{ + EntityID: &config.EntityID, + IssuerURI: &config.IssuerURI, + IDPImageURL: &config.IDPImageURL, + Metadata: &config.Metadata, + MetadataURL: &config.MetadataURL, + IDPName: &config.IDPName, + EnableSSO: &config.EnableSSO, + }, } if response.SMTPSettings.SMTPPassword != nil { *response.SMTPSettings.SMTPPassword = "********" diff --git a/server/service/endpoint_appconfig_test.go b/server/service/endpoint_appconfig_test.go index a2c84ae4ca..6fc60c7cd7 100644 --- a/server/service/endpoint_appconfig_test.go +++ b/server/service/endpoint_appconfig_test.go @@ -53,6 +53,11 @@ func testModifyAppConfig(t *testing.T, r *testResource) { SMTPEnableTLS: true, SMTPVerifySSLCerts: true, SMTPEnableStartTLS: true, + EnableSSO: true, + IDPName: "idpname", + Metadata: "metadataxxxxxx", + IssuerURI: "http://issuer.idp.com", + EntityID: "kolide", } payload := appConfigPayloadFromAppConfig(config) payload.SMTPTest = new(bool) @@ -76,6 +81,12 @@ func testModifyAppConfig(t *testing.T, r *testResource) { require.Nil(t, err) // verify email test succeeded assert.True(t, saved.SMTPConfigured) + // verify that SSO stuff was saved + assert.True(t, saved.EnableSSO) + assert.Equal(t, "idpname", saved.IDPName) + assert.Equal(t, "metadataxxxxxx", saved.Metadata) + assert.Equal(t, "http://issuer.idp.com", saved.IssuerURI) + assert.Equal(t, "kolide", saved.EntityID) } @@ -115,5 +126,13 @@ func appConfigPayloadFromAppConfig(config *kolide.AppConfig) *kolide.AppConfigPa KolideServerURL: &config.KolideServerURL, }, SMTPSettings: smtpSettingsFromAppConfig(config), + SSOSettings: &kolide.SSOSettingsPayload{ + EnableSSO: &config.EnableSSO, + IDPName: &config.IDPName, + Metadata: &config.Metadata, + MetadataURL: &config.MetadataURL, + IssuerURI: &config.IssuerURI, + EntityID: &config.EntityID, + }, } } diff --git a/server/service/endpoint_decorators.go b/server/service/endpoint_decorators.go index ecc5611e1e..bcd0cbcc38 100644 --- a/server/service/endpoint_decorators.go +++ b/server/service/endpoint_decorators.go @@ -60,6 +60,7 @@ func makeDeleteDecoratorEndpoint(svc kolide.Service) endpoint.Endpoint { return func(ctx context.Context, request interface{}) (interface{}, error) { r := request.(deleteDecoratorRequest) err := svc.DeleteDecorator(ctx, r.ID) + if err != nil { return deleteDecoratorResponse{Err: err}, nil } @@ -75,5 +76,6 @@ func makeModifyDecoratorEndpoint(svc kolide.Service) endpoint.Endpoint { return decoratorResponse{Err: err}, nil } return decoratorResponse{Decorator: dec}, nil + } } diff --git a/server/service/endpoint_decorators_test.go b/server/service/endpoint_decorators_test.go index f8b14a42a7..f3298b7682 100644 --- a/server/service/endpoint_decorators_test.go +++ b/server/service/endpoint_decorators_test.go @@ -157,7 +157,6 @@ func testNewDecoratorFailType(t *testing.T, r *testResource) { require.Nil(t, err) require.Len(t, errStruct.Errors, 1) assert.Equal(t, "invalid value, must be load, always, or interval", errStruct.Errors[0].Reason) - } func testNewDecoratorFailValidation(t *testing.T, r *testResource) { diff --git a/server/service/endpoint_sessions.go b/server/service/endpoint_sessions.go index 9402989092..c07b479ec1 100644 --- a/server/service/endpoint_sessions.go +++ b/server/service/endpoint_sessions.go @@ -1,7 +1,9 @@ package service import ( + "bytes" "context" + "html/template" "time" "github.com/go-kit/kit/endpoint" @@ -172,3 +174,74 @@ func makeDeleteSessionsForUserEndpoint(svc kolide.Service) endpoint.Endpoint { return deleteSessionsForUserResponse{}, nil } } + +type initiateSSORequest struct { + RelayURL string `json:"relay_url"` +} + +type initiateSSOResponse struct { + URL string `json:"url,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r initiateSSOResponse) error() error { return r.Err } + +func makeInitiateSSOEndpoint(svc kolide.Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + req := request.(initiateSSORequest) + idProviderURL, err := svc.InitiateSSO(ctx, req.RelayURL) + if err != nil { + return initiateSSOResponse{Err: err}, nil + } + return initiateSSOResponse{URL: idProviderURL}, nil + } +} + +type callbackSSOResponse struct { + content string + Err error `json:"error,omitempty"` +} + +func (r callbackSSOResponse) error() error { return r.Err } + +// If html is present we return a web page +func (r callbackSSOResponse) html() string { return r.content } + +func makeCallbackSSOEndpoint(svc kolide.Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + authResponse := request.(kolide.Auth) + session, err := svc.CallbackSSO(ctx, authResponse) + var resp callbackSSOResponse + if err != nil { + // redirect to login page on front end if there was some problem, + // errors should still be logged + session = &kolide.SSOSession{ + RedirectURL: "/login", + Token: "", + } + resp.Err = err + } + relayStateLoadPage := ` + +
+ Redirecting to Kolide... + + + ` + tmpl, err := template.New("relayStateLoader").Parse(relayStateLoadPage) + if err != nil { + return nil, err + } + var writer bytes.Buffer + err = tmpl.Execute(&writer, session) + if err != nil { + return nil, err + } + resp.content = writer.String() + return resp, nil + } +} diff --git a/server/service/handler.go b/server/service/handler.go index 6d32ffabaa..cb68af8c18 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -83,6 +83,8 @@ type KolideEndpoints struct { ChangeEmail endpoint.Endpoint UpdateLicense endpoint.Endpoint GetLicense endpoint.Endpoint + InitiateSSO endpoint.Endpoint + CallbackSSO endpoint.Endpoint } // MakeKolideServerEndpoints creates the Kolide API endpoints. @@ -94,6 +96,8 @@ func MakeKolideServerEndpoints(svc kolide.Service, jwtKey string) KolideEndpoint ResetPassword: makeResetPasswordEndpoint(svc), CreateUser: makeCreateUserEndpoint(svc), VerifyInvite: makeVerifyInviteEndpoint(svc), + InitiateSSO: makeInitiateSSOEndpoint(svc), + CallbackSSO: makeCallbackSSOEndpoint(svc), // Authenticated user endpoints // Each of these endpoints should have exactly one @@ -240,6 +244,8 @@ type kolideHandlers struct { ChangeEmail http.Handler UpdateLicense http.Handler GetLicense http.Handler + InitiateSSO http.Handler + CallbackSSO http.Handler } func makeKolideKitHandlers(e KolideEndpoints, opts []kithttp.ServerOption) *kolideHandlers { @@ -315,6 +321,8 @@ func makeKolideKitHandlers(e KolideEndpoints, opts []kithttp.ServerOption) *koli ChangeEmail: newServer(e.ChangeEmail, decodeChangeEmailRequest), UpdateLicense: newServer(e.UpdateLicense, decodeLicenseRequest), GetLicense: newServer(e.GetLicense, decodeNoParamsRequest), + InitiateSSO: newServer(e.InitiateSSO, decodeInitiateSSORequest), + CallbackSSO: newServer(e.CallbackSSO, decodeCallbackSSORequest), } } @@ -363,7 +371,8 @@ func attachKolideAPIRoutes(r *mux.Router, h *kolideHandlers) { r.Handle("/api/v1/kolide/me", h.Me).Methods("GET").Name("me") r.Handle("/api/v1/kolide/change_password", h.ChangePassword).Methods("POST").Name("change_password") r.Handle("/api/v1/kolide/perform_required_password_reset", h.PerformRequiredPasswordReset).Methods("POST").Name("perform_required_password_reset") - + r.Handle("/api/v1/kolide/sso", h.InitiateSSO).Methods("POST").Name("intiate_sso") + r.Handle("/api/v1/kolide/sso/callback", h.CallbackSSO).Methods("POST").Name("callback_sso") r.Handle("/api/v1/kolide/users", h.ListUsers).Methods("GET").Name("list_users") r.Handle("/api/v1/kolide/users", h.CreateUser).Methods("POST").Name("create_user") r.Handle("/api/v1/kolide/users/{id}", h.GetUser).Methods("GET").Name("get_user") diff --git a/server/service/logging_sessions.go b/server/service/logging_sessions.go index d80ac90b1a..fc98996f36 100644 --- a/server/service/logging_sessions.go +++ b/server/service/logging_sessions.go @@ -34,3 +34,28 @@ func (mw loggingMiddleware) Logout(ctx context.Context) (err error) { err = mw.Service.Logout(ctx) return } + +func (mw loggingMiddleware) InitiateSSO(ctx context.Context, relayURL string) (idpURL string, err error) { + defer func(begin time.Time) { + _ = mw.logger.Log( + "method", "InitiateSSO", + "err", err, + "took", time.Since(begin), + ) + }(time.Now()) + + idpURL, err = mw.Service.InitiateSSO(ctx, relayURL) + return +} + +func (mw loggingMiddleware) CallbackSSO(ctx context.Context, auth kolide.Auth) (sess *kolide.SSOSession, err error) { + defer func(begin time.Time) { + _ = mw.logger.Log( + "method", "CallbackSSO", + "err", err, + "took", time.Since(begin), + ) + }(time.Now()) + sess, err = mw.Service.CallbackSSO(ctx, auth) + return +} diff --git a/server/service/metrics_sessions.go b/server/service/metrics_sessions.go index 97a43428f5..fd0ac55050 100644 --- a/server/service/metrics_sessions.go +++ b/server/service/metrics_sessions.go @@ -8,6 +8,26 @@ import ( "github.com/kolide/kolide/server/kolide" ) +func (mw metricsMiddleware) InitiateSSO(ctx context.Context, relayValue string) (idpURL string, err error) { + defer func(begin time.Time) { + lvs := []string{"method", "InitiateSSO", "error", fmt.Sprint(err != nil)} + mw.requestCount.With(lvs...).Add(1) + mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds()) + }(time.Now()) + idpURL, err = mw.Service.InitiateSSO(ctx, relayValue) + return +} + +func (mw metricsMiddleware) CallbackSSO(ctx context.Context, auth kolide.Auth) (sess *kolide.SSOSession, err error) { + defer func(begin time.Time) { + lvs := []string{"method", "CallbackSSO", "error", fmt.Sprint(err != nil)} + mw.requestCount.With(lvs...).Add(1) + mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds()) + }(time.Now()) + sess, err = mw.Service.CallbackSSO(ctx, auth) + return +} + func (mw metricsMiddleware) Login(ctx context.Context, username string, password string) (*kolide.User, string, error) { var ( user *kolide.User diff --git a/server/service/service.go b/server/service/service.go index 5ee2fcaa6f..5c039f5ce3 100644 --- a/server/service/service.go +++ b/server/service/service.go @@ -13,11 +13,14 @@ import ( "github.com/kolide/kolide/server/config" "github.com/kolide/kolide/server/kolide" "github.com/kolide/kolide/server/logwriter" + "github.com/kolide/kolide/server/sso" lumberjack "gopkg.in/natefinch/lumberjack.v2" ) // NewService creates a new service from the config struct -func NewService(ds kolide.Datastore, resultStore kolide.QueryResultStore, logger kitlog.Logger, kolideConfig config.KolideConfig, mailService kolide.MailService, c clock.Clock, checker kolide.LicenseChecker) (kolide.Service, error) { +func NewService(ds kolide.Datastore, resultStore kolide.QueryResultStore, + logger kitlog.Logger, kolideConfig config.KolideConfig, mailService kolide.MailService, + c clock.Clock, checker kolide.LicenseChecker, sso sso.SessionStore) (kolide.Service, error) { var svc kolide.Service statusWriter, err := osqueryLogFile(kolideConfig.Osquery.StatusLogFile, logger, kolideConfig.Osquery.EnableLogRotation) if err != nil { @@ -39,8 +42,9 @@ func NewService(ds kolide.Datastore, resultStore kolide.QueryResultStore, logger osqueryStatusLogWriter: statusWriter, osqueryResultLogWriter: resultWriter, mailService: mailService, + ssoSessionStore: sso, } - svc = validationMiddleware{svc, ds} + svc = validationMiddleware{svc, ds, sso} return svc, nil } @@ -83,7 +87,8 @@ type service struct { osqueryStatusLogWriter io.Writer osqueryResultLogWriter io.Writer - mailService kolide.MailService + mailService kolide.MailService + ssoSessionStore sso.SessionStore } func (s service) SendEmail(mail kolide.Email) error { @@ -93,3 +98,9 @@ func (s service) SendEmail(mail kolide.Email) error { func (s service) Clock() clock.Clock { return s.clock } + +type validationMiddleware struct { + kolide.Service + ds kolide.Datastore + ssoSessionStore sso.SessionStore +} diff --git a/server/service/service_appconfig.go b/server/service/service_appconfig.go index 4ed2dad17f..84a70adf0d 100644 --- a/server/service/service_appconfig.go +++ b/server/service/service_appconfig.go @@ -114,6 +114,30 @@ func appConfigFromAppConfigPayload(p kolide.AppConfigPayload, config kolide.AppC config.EnrollSecret = *p.ServerSettings.EnrollSecret } + if p.SSOSettings != nil { + if p.SSOSettings.EnableSSO != nil { + config.EnableSSO = *p.SSOSettings.EnableSSO + } + if p.SSOSettings.EntityID != nil { + config.EntityID = *p.SSOSettings.EntityID + } + if p.SSOSettings.IDPImageURL != nil { + config.IDPImageURL = *p.SSOSettings.IDPImageURL + } + if p.SSOSettings.IDPName != nil { + config.IDPName = *p.SSOSettings.IDPName + } + if p.SSOSettings.IssuerURI != nil { + config.IssuerURI = *p.SSOSettings.IssuerURI + } + if p.SSOSettings.Metadata != nil { + config.Metadata = *p.SSOSettings.Metadata + } + if p.SSOSettings.MetadataURL != nil { + config.MetadataURL = *p.SSOSettings.MetadataURL + } + } + populateSMTP := func(p *kolide.SMTPSettingsPayload) { if p.SMTPAuthenticationMethod != nil { switch *p.SMTPAuthenticationMethod { diff --git a/server/service/service_invites_test.go b/server/service/service_invites_test.go index 545d4fcd7d..86527a880d 100644 --- a/server/service/service_invites_test.go +++ b/server/service/service_invites_test.go @@ -94,7 +94,7 @@ func setupInviteTest(t *testing.T) (kolide.Service, *mock.Store, *mockMailServic config: config.TestConfig(), mailService: mailer, clock: clock.NewMockClock(), - }, ms} + }, ms, nil} return svc, ms, mailer } diff --git a/server/service/service_sessions.go b/server/service/service_sessions.go index 5d403c4120..0c76de6c0b 100644 --- a/server/service/service_sessions.go +++ b/server/service/service_sessions.go @@ -4,17 +4,101 @@ import ( "context" "crypto/rand" "encoding/base64" + "net/url" "strings" "time" jwt "github.com/dgrijalva/jwt-go" "github.com/kolide/kolide/server/contexts/viewer" "github.com/kolide/kolide/server/kolide" + "github.com/kolide/kolide/server/sso" "github.com/pkg/errors" ) -func (svc service) SSOLogin(ctx context.Context, userId string) (*kolide.User, string, error) { - return nil, "", errors.New("not implemented") +func (svc service) InitiateSSO(ctx context.Context, redirectURL string) (string, error) { + appConfig, err := svc.ds.AppConfig() + if err != nil { + return "", errors.Wrap(err, "InitiateSSO getting app config") + } + + metadata, err := getMetadata(appConfig) + if err != nil { + return "", errors.Wrap(err, "InitiateSSO getting metadata") + } + + settings := sso.Settings{ + Metadata: metadata, + // Construct call back url to send to idp + AssertionConsumerServiceURL: appConfig.KolideServerURL + "/api/v1/kolide/sso/callback", + SessionStore: svc.ssoSessionStore, + OriginalURL: redirectURL, + } + + // If issuer is not explicitly set, default to host name. + var issuer string + if appConfig.EntityID == "" { + u, err := url.Parse(appConfig.KolideServerURL) + if err != nil { + return "", errors.Wrap(err, "parsing kolide server url") + } + issuer = u.Hostname() + } else { + issuer = appConfig.EntityID + } + idpURL, err := sso.CreateAuthorizationRequest(&settings, issuer) + if err != nil { + return "", errors.Wrap(err, "InitiateSSO creating authorization") + } + + return idpURL, nil +} + +func getMetadata(config *kolide.AppConfig) (*sso.Metadata, error) { + if config.MetadataURL != "" { + metadata, err := sso.GetMetadata(config.MetadataURL, 5*time.Second) + if err != nil { + return nil, err + } + return metadata, nil + } + if config.Metadata != "" { + metadata, err := sso.ParseMetadata(config.Metadata) + if err != nil { + return nil, err + } + return metadata, nil + } + return nil, errors.Errorf("missing metadata for idp %s", config.IDPName) +} + +func (svc service) CallbackSSO(ctx context.Context, auth kolide.Auth) (*kolide.SSOSession, error) { + // The signature and validity of auth response has been checked already in + // validation middleware. + sess, err := svc.ssoSessionStore.Get(auth.RequestID()) + if err != nil { + return nil, errors.Wrap(err, "fetching sso session in callback") + } + // Remove session to so that is can't be reused before it expires. + err = svc.ssoSessionStore.Expire(auth.RequestID()) + if err != nil { + return nil, errors.Wrap(err, "expiring sso session in callback") + } + user, err := svc.userByEmailOrUsername(auth.UserID()) + if err != nil { + return nil, errors.Wrap(err, "finding user in sso callback") + } + token, err := svc.makeSession(user.ID) + if err != nil { + return nil, errors.Wrap(err, "making user session in sso callback") + } + result := &kolide.SSOSession{ + Token: token, + RedirectURL: sess.OriginalURL, + } + if !strings.HasPrefix(result.RedirectURL, "/") { + result.RedirectURL = "/" + result.RedirectURL + } + return result, nil } func (svc service) Login(ctx context.Context, username, password string) (*kolide.User, string, error) { diff --git a/server/service/transport.go b/server/service/transport.go index ecff7a3ccf..3fd0485f59 100644 --- a/server/service/transport.go +++ b/server/service/transport.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "strconv" @@ -29,6 +30,12 @@ func encodeResponse(ctx context.Context, w http.ResponseWriter, response interfa } } + if page, ok := response.(htmlPage); ok { + w.Header().Set("Content-Type", "text/html; charset=UTF-8") + _, err := io.WriteString(w, page.html()) + return err + } + enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(response) @@ -40,6 +47,12 @@ type statuser interface { status() int } +// loads a html page +type htmlPage interface { + html() string + error() error +} + func idFromRequest(r *http.Request, name string) (uint, error) { vars := mux.Vars(r) id, ok := vars[name] diff --git a/server/service/transport_sessions.go b/server/service/transport_sessions.go index 62970f45df..c11af7a5e8 100644 --- a/server/service/transport_sessions.go +++ b/server/service/transport_sessions.go @@ -5,6 +5,9 @@ import ( "encoding/json" "net/http" "strings" + + "github.com/kolide/kolide/server/sso" + "github.com/pkg/errors" ) func decodeGetInfoAboutSessionRequest(ctx context.Context, r *http.Request) (interface{}, error) { @@ -47,3 +50,24 @@ func decodeLoginRequest(ctx context.Context, r *http.Request) (interface{}, erro req.Username = strings.ToLower(req.Username) return req, nil } + +func decodeInitiateSSORequest(ctx context.Context, r *http.Request) (interface{}, error) { + var req initiateSSORequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + return nil, err + } + return req, nil +} + +func decodeCallbackSSORequest(ctx context.Context, r *http.Request) (interface{}, error) { + err := r.ParseForm() + if err != nil { + return nil, errors.Wrap(err, "decode sso callback") + } + authResponse, err := sso.DecodeAuthResponse(r.FormValue("SAMLResponse")) + if err != nil { + return nil, errors.Wrap(err, "decoding sso callback") + } + return authResponse, nil +} diff --git a/server/service/util_test.go b/server/service/util_test.go index 1fabd85adc..62b1e85be0 100644 --- a/server/service/util_test.go +++ b/server/service/util_test.go @@ -12,12 +12,12 @@ import ( func newTestService(ds kolide.Datastore, rs kolide.QueryResultStore) (kolide.Service, error) { mailer := &mockMailService{SendEmailFn: func(e kolide.Email) error { return nil }} - return NewService(ds, rs, kitlog.NewNopLogger(), config.TestConfig(), mailer, clock.C, nil) + return NewService(ds, rs, kitlog.NewNopLogger(), config.TestConfig(), mailer, clock.C, nil, nil) } func newTestServiceWithClock(ds kolide.Datastore, rs kolide.QueryResultStore, c clock.Clock) (kolide.Service, error) { mailer := &mockMailService{SendEmailFn: func(e kolide.Email) error { return nil }} - return NewService(ds, rs, kitlog.NewNopLogger(), config.TestConfig(), mailer, c, nil) + return NewService(ds, rs, kitlog.NewNopLogger(), config.TestConfig(), mailer, c, nil, nil) } func createTestAppConfig(t *testing.T, ds kolide.Datastore) *kolide.AppConfig { diff --git a/server/service/validation_app_config.go b/server/service/validation_app_config.go new file mode 100644 index 0000000000..75dff2ab15 --- /dev/null +++ b/server/service/validation_app_config.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + + "github.com/kolide/kolide/server/kolide" +) + +func (mw validationMiddleware) ModifyAppConfig(ctx context.Context, p kolide.AppConfigPayload) (*kolide.AppConfig, error) { + invalid := &invalidArgumentError{} + validateSSOSettings(p, invalid) + if invalid.HasErrors() { + return nil, invalid + } + return mw.Service.ModifyAppConfig(ctx, p) +} + +func isSet(val *string) bool { + if val != nil { + return len(*val) > 0 + } + return false +} + +func validateSSOSettings(p kolide.AppConfigPayload, invalid *invalidArgumentError) { + if p.SSOSettings != nil && p.SSOSettings.EnableSSO != nil { + if *p.SSOSettings.EnableSSO { + if !isSet(p.SSOSettings.Metadata) && !isSet(p.SSOSettings.MetadataURL) { + invalid.Append("metadata", "either metadata or metadata_url must be defined") + } + if isSet(p.SSOSettings.Metadata) && isSet(p.SSOSettings.MetadataURL) { + invalid.Append("metadata", "both metadata and metadata_url are defined, only one is allowed") + } + if !isSet(p.SSOSettings.EntityID) { + invalid.Append("entity_id", "required") + } else { + if len(*p.SSOSettings.EntityID) < 5 { + invalid.Append("entity_id", "must be 5 or more characters") + } + } + if !isSet(p.SSOSettings.IDPName) { + invalid.Append("idp_name", "required") + } else { + if len(*p.SSOSettings.IDPName) < 5 { + invalid.Append("idp_name", "must be 5 or more characters") + } + } + } + } +} diff --git a/server/service/validation_app_config_test.go b/server/service/validation_app_config_test.go new file mode 100644 index 0000000000..ad5355710c --- /dev/null +++ b/server/service/validation_app_config_test.go @@ -0,0 +1,47 @@ +package service + +import ( + "testing" + + "github.com/kolide/kolide/server/kolide" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSSONotPresent(t *testing.T) { + invalid := &invalidArgumentError{} + var p kolide.AppConfigPayload + validateSSOSettings(p, invalid) + assert.False(t, invalid.HasErrors()) + +} + +func TestNeedFieldsPresent(t *testing.T) { + invalid := &invalidArgumentError{} + config := kolide.AppConfig{ + EnableSSO: true, + EntityID: "kolide", + IssuerURI: "http://issuer.idp.com", + MetadataURL: "http://isser.metadata.com", + IDPName: "onelogin", + } + p := appConfigPayloadFromAppConfig(&config) + validateSSOSettings(*p, invalid) + assert.False(t, invalid.HasErrors()) +} + +func TestMissingMetadata(t *testing.T) { + invalid := invalidArgumentError{} + config := kolide.AppConfig{ + EnableSSO: true, + EntityID: "kolide", + IssuerURI: "http://issuer.idp.com", + IDPName: "onelogin", + } + p := appConfigPayloadFromAppConfig(&config) + validateSSOSettings(*p, &invalid) + require.True(t, invalid.HasErrors()) + require.Len(t, invalid, 1) + assert.Equal(t, "metadata", invalid[0].name) + assert.Equal(t, "either metadata or metadata_url must be defined", invalid[0].reason) +} diff --git a/server/service/validation_sessions.go b/server/service/validation_sessions.go new file mode 100644 index 0000000000..1b46c31c5c --- /dev/null +++ b/server/service/validation_sessions.go @@ -0,0 +1,35 @@ +package service + +import ( + "context" + + "github.com/kolide/kolide/server/kolide" + "github.com/kolide/kolide/server/sso" + "github.com/pkg/errors" +) + +func (mw validationMiddleware) CallbackSSO(ctx context.Context, auth kolide.Auth) (*kolide.SSOSession, error) { + invalid := &invalidArgumentError{} + session, err := mw.ssoSessionStore.Get(auth.RequestID()) + if err != nil { + invalid.Append("session", "missing for request") + return nil, invalid + } + validator, err := sso.NewValidator(session.Metadata) + if err != nil { + return nil, errors.Wrap(err, "creating validator from metadata") + } + // make sure the response hasn't been tampered with + auth, err = validator.ValidateSignature(auth) + if err != nil { + invalid.Appendf("sso response", "signature validation failed %s", err.Error()) + return nil, invalid + } + // make sure the response isn't stale + err = validator.ValidateResponse(auth) + if err != nil { + invalid.Appendf("sso response", "response validation failed %s", err.Error()) + } + + return mw.Service.CallbackSSO(ctx, auth) +} diff --git a/server/service/validation_users.go b/server/service/validation_users.go index cb06386cd7..ca2288aaad 100644 --- a/server/service/validation_users.go +++ b/server/service/validation_users.go @@ -10,11 +10,6 @@ import ( "github.com/kolide/kolide/server/kolide" ) -type validationMiddleware struct { - kolide.Service - ds kolide.Datastore -} - func (mw validationMiddleware) NewUser(ctx context.Context, p kolide.UserPayload) (*kolide.User, error) { invalid := &invalidArgumentError{} if p.Username == nil { diff --git a/server/sso/authorization_request.go b/server/sso/authorization_request.go new file mode 100644 index 0000000000..ea871560a9 --- /dev/null +++ b/server/sso/authorization_request.go @@ -0,0 +1,136 @@ +package sso + +import ( + "bytes" + "compress/flate" + "encoding/base64" + "encoding/xml" + "net/url" + "time" + + "github.com/kolide/kolide/server/kolide" + "github.com/pkg/errors" +) + +const ( + samlVersion = "2.0" + cacheLifetime = 300 // five minutes +) + +// RelayState sets optional relay state +func RelayState(v string) func(*opts) { + return func(o *opts) { + o.relayState = v + } +} + +type opts struct { + relayState string +} + +// CreateAuthorizationRequest creates a url suitable for use to satisfy the SAML +// redirect binding. +// See http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf Section 3.4 +func CreateAuthorizationRequest(settings *Settings, issuer string, options ...func(o *opts)) (string, error) { + var optionalParams opts + for _, opt := range options { + opt(&optionalParams) + } + if settings.Metadata == nil { + return "", errors.New("missing settings metadata") + } + requestID, err := kolide.RandomText(16) + if err != nil { + return "", errors.Wrap(err, "creating auth request id") + } + destinationURL, err := getDestinationURL(settings) + if err != nil { + return "", errors.Wrap(err, "creating auth request") + } + request := AuthnRequest{ + XMLName: xml.Name{ + Local: "samlp:AuthnRequest", + }, + ID: requestID, + SAMLP: "urn:oasis:names:tc:SAML:2.0:protocol", + SAML: "urn:oasis:names:tc:SAML:2.0:assertion", + AssertionConsumerServiceURL: settings.AssertionConsumerServiceURL, + Destination: destinationURL, + IssueInstant: time.Now().UTC().Format("2006-01-02T15:04:05Z"), + ProtocolBinding: RedirectBinding, + Version: samlVersion, + ProviderName: "Kolide", + Issuer: Issuer{ + XMLName: xml.Name{ + Local: "saml:Issuer", + }, + Url: issuer, + }, + } + var reader bytes.Buffer + err = xml.NewEncoder(&reader).Encode(settings.Metadata) + if err != nil { + return "", errors.Wrap(err, "encoding metadata creating auth request") + } + // cache metadata so we can check the signatures on the response we get from the IDP + err = settings.SessionStore.create(requestID, + settings.OriginalURL, + reader.String(), + cacheLifetime, + ) + if err != nil { + return "", errors.Wrap(err, "caching cert while creating auth request") + } + u, err := url.Parse(destinationURL) + if err != nil { + return "", errors.Wrap(err, "parsing destination url") + } + qry := u.Query() + + var writer bytes.Buffer + err = xml.NewEncoder(&writer).Encode(request) + if err != nil { + return "", errors.Wrap(err, "encoding auth request xml") + } + authQueryVal, err := deflate(&writer) + if err != nil { + return "", errors.Wrap(err, "unable to compress auth info") + } + qry.Set("SAMLRequest", authQueryVal) + if optionalParams.relayState != "" { + qry.Set("RelayState", optionalParams.relayState) + } + u.RawQuery = qry.Encode() + return u.String(), nil +} + +func getDestinationURL(settings *Settings) (string, error) { + for _, sso := range settings.Metadata.IDPSSODescriptor.SingleSignOnService { + if sso.Binding == RedirectBinding { + return sso.Location, nil + } + } + return "", errors.New("IDP does not support redirect binding") +} + +// See SAML Bindings http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf +// Section 3.4.4.1 +func deflate(xmlBuffer *bytes.Buffer) (string, error) { + var deflated bytes.Buffer + writer, err := flate.NewWriter(&deflated, flate.DefaultCompression) + if err != nil { + return "", err + } + defer writer.Close() + n, err := writer.Write(xmlBuffer.Bytes()) + if n != xmlBuffer.Len() { + return "", errors.New("incomplete write during compression") + } + if err != nil { + return "", errors.Wrap(err, "compressing auth request") + } + writer.Flush() + encbuff := deflated.Bytes() + encoded := base64.StdEncoding.EncodeToString(encbuff) + return encoded, nil +} diff --git a/server/sso/authorization_request_test.go b/server/sso/authorization_request_test.go new file mode 100644 index 0000000000..ecb4fdf6ff --- /dev/null +++ b/server/sso/authorization_request_test.go @@ -0,0 +1,18 @@ +package sso + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequestCompression(t *testing.T) { + input := "