Server Side SSO Support (#1498)

This PR partially addresses #1456, providing SSO SAML support. The flow of the code is as follows.

A Kolide user attempts to access a protected resource and is directed to log in.
If SSO identity providers (IDP) have been configured by an admin, the user is presented with SSO log in.
The user selects SSO, which invokes a call the InitiateSSO passing the URL of the protected resource that the user was originally trying access. Kolide server loads the IDP metadata and caches it along with the URL. We then build an auth request URL for the IDP which is returned to the front end.
The IDP calls the server, invoking CallbackSSO with the auth response.
We extract the original request id from the response and use it to fetch the cached metadata and the URL. We check the signature of the response, and validate the timestamps. If everything passes we get the user id from the IDP response and use it to create a login session. We then build a page which executes some javascript that will write the token to web local storage, and redirect to the original URL.
I've created a test web page in tools/app/authtest.html that can be used to test and debug new IDP's which also illustrates how a front end would interact with the IDP and the server. This page can be loaded by starting Kolide with the environment variable KOLIDE_TEST_PAGE_PATH to the full path of the page and then accessed at https://localhost:8080/test
This commit is contained in:
John Murphy 2017-05-08 19:43:48 -05:00 committed by GitHub
parent 5a69cf1530
commit 368b9d774c
50 changed files with 1946 additions and 428 deletions

View file

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

View file

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

View file

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

19
glide.lock generated
View file

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

View file

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

View file

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

View file

@ -80,5 +80,4 @@ var testFunctions = [...]func(*testing.T, kolide.Datastore){
testCountHostsInTargets,
testHostStatus,
testResetOptions,
testIdentityProvider,
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,7 +19,6 @@ type Datastore interface {
FileIntegrityMonitoringStore
YARAStore
LicenseStore
IdentityProviderStore
Name() string
Drop() error
// MigrateTables creates and migrates the table schemas

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = "********"

View file

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

View file

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

View file

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

View file

@ -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 := ` <html>
<script type='text/javascript'>
var redirectURL = {{.RedirectURL}};
window.localStorage.setItem('KOLIDE::auth_token', '{{.Token}}');
window.location = redirectURL;
</script>
<body>
Redirecting to Kolide...
</body>
</html>
`
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
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 := "<samlp:AuthnRequest AssertionConsumerServiceURL='https://sp.example.com/acs' Destination='https://idp.example.com/sso' ID='_18185425-fd62-477c-b9d4-4b5d53a89845' IssueInstant='2017-04-16T15:32:42Z' ProtocolBinding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Version='2.0' xmlns:saml='urn:oasis:names:tc:SAML:2.0:assertion' xmlns:samlp='urn:oasis:names:tc:SAML:2.0:protocol'><saml:Issuer>https://sp.example.com/saml2</saml:Issuer><samlp:NameIDPolicy AllowCreate='true' Format='urn:oasis:names:tc:SAML:2.0:nameid-format:transient'/></samlp:AuthnRequest>"
expected := "fJJf79IwFIa/Su961f0pG4yGLZkQ4xLUBaYX3piyHaTJ2s6eTvHbmw2McPHjtnne9u1zzgal7gdRjv5iDvBzBPSkRATnlTVba3DU4I7gfqkWvhz2Ob14P6AIQxwCuEo99BC0VoeyRUp2gF4ZOUX/g6p7JhEtJdUup9/jLM7ShKfs3C05S1arlp3WXcKSU9qlC5mtsySlpEIcoTLopfE55VG8YlHC4mUTp2LBRcK/UVI7621r+3fKdMr8yOnojLASFQojNaDwrTiWH/eCB5E43SAUH5qmZvXnY0PJV3A4t+ZBRMlV9wbFZOb1TfKfqMfI8Doz3KvSYlYv5u+54g2tE8I34SN5n9gnqaHa1bZX7R9S9r39vXUgPeTUuxEoeW+dlv51l+lEdew8o8I7aVCB8TQsbk8+70XxFwAA//8="
buff := bytes.NewBufferString(input)
compressed, err := deflate(buff)
require.Nil(t, err)
assert.Equal(t, expected, compressed)
}

View file

@ -0,0 +1,132 @@
package sso
import (
"bytes"
"encoding/base64"
"encoding/xml"
"github.com/kolide/kolide/server/kolide"
"github.com/pkg/errors"
)
const (
// These are response status codes described in the core SAML spec section
// 3.2.2.1 See http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf
Success int = iota
Requestor
Responder
VersionMismatch
AuthnFailed
InvalidAttrNameOrValue
InvalidNameIDPolicy
NoAuthnContext
NoAvailableIDP
NoPassive
NoSupportedIDP
PartialLogout
ProxyCountExceeded
RequestDenied
RequestUnsupported
RequestVersionDeprecated
RequestVersionTooHigh
RequestVersionTooLow
ResourceNotRecognized
TooManyResponses
UnknownAttrProfile
UnknownPrincipal
UnsupportedBinding
)
var statusMap = map[string]int{
"urn:oasis:names:tc:SAML:2.0:status:Success": Success,
"urn:oasis:names:tc:SAML:2.0:status:Requester": Requestor,
"urn:oasis:names:tc:SAML:2.0:status:Responder": Responder,
"urn:oasis:names:tc:SAML:2.0:status:VersionMismatch": VersionMismatch,
"urn:oasis:names:tc:SAML:2.0:status:AuthnFailed": AuthnFailed,
"urn:oasis:names:tc:SAML:2.0:status:InvalidAttrNameOrValue": InvalidAttrNameOrValue,
"urn:oasis:names:tc:SAML:2.0:status:InvalidNameIDPolicy": InvalidNameIDPolicy,
"urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext": NoAuthnContext,
"urn:oasis:names:tc:SAML:2.0:status:NoAvailableIDP": NoAvailableIDP,
"urn:oasis:names:tc:SAML:2.0:status:NoPassive": NoPassive,
"urn:oasis:names:tc:SAML:2.0:status:NoSupportedIDP": NoSupportedIDP,
"urn:oasis:names:tc:SAML:2.0:status:PartialLogout": PartialLogout,
"urn:oasis:names:tc:SAML:2.0:status:ProxyCountExceeded": ProxyCountExceeded,
"urn:oasis:names:tc:SAML:2.0:status:RequestDenied": RequestDenied,
"urn:oasis:names:tc:SAML:2.0:status:RequestUnsupported": RequestUnsupported,
"urn:oasis:names:tc:SAML:2.0:status:RequestVersionDeprecated": RequestVersionDeprecated,
"urn:oasis:names:tc:SAML:2.0:status:RequestVersionTooLow": RequestVersionTooLow,
"urn:oasis:names:tc:SAML:2.0:status:ResourceNotRecognized": ResourceNotRecognized,
"urn:oasis:names:tc:SAML:2.0:status:TooManyResponses": TooManyResponses,
"urn:oasis:names:tc:SAML:2.0:status:UnknownAttrProfile": UnknownAttrProfile,
"urn:oasis:names:tc:SAML:2.0:status:UnknownPrincipal": UnknownPrincipal,
"urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding": UnsupportedBinding,
}
type resp struct {
response *Response
rawResp string
}
func (r *resp) setResponse(val *Response) {
r.response = val
}
func (r resp) statusDescription() string {
if r.response != nil {
return r.response.Status.StatusCode.Value
}
return "missing response"
}
func (r resp) UserID() string {
if r.response != nil {
return r.response.Assertion.Subject.NameID.Value
}
return ""
}
func (r resp) status() (int, error) {
if r.response != nil {
statusURI := r.response.Status.StatusCode.Value
if code, ok := statusMap[statusURI]; ok {
return code, nil
}
}
return AuthnFailed, errors.New("malformed or missing auth response")
}
func (r resp) RequestID() string {
if r.response != nil {
return r.response.InResponseTo
}
return ""
}
func (r resp) rawResponse() string {
return r.rawResp
}
func (r resp) authResponse() (*Response, error) {
if r.response != nil {
return r.response, nil
}
return nil, errors.New("missing SAML response")
}
// DecodeAuthResponse extracts SAML assertions from IDP response
func DecodeAuthResponse(samlResponse string) (kolide.Auth, error) {
var authInfo resp
authInfo.rawResp = samlResponse
decoded, err := base64.StdEncoding.DecodeString(samlResponse)
if err != nil {
return nil, errors.Wrap(err, "decoding saml response")
}
var saml Response
err = xml.NewDecoder(bytes.NewBuffer(decoded)).Decode(&saml)
if err != nil {
return nil, errors.Wrap(err, "decoding response xml")
}
authInfo.response = &saml
return &authInfo, nil
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,90 @@
package sso
import (
"bytes"
"encoding/json"
"time"
"github.com/garyburd/redigo/redis"
"github.com/pkg/errors"
)
// Session stores state for the lifetime of a single sign on session
type Session struct {
// OriginalURL is the resource being accessed when login request was triggered
OriginalURL string `json:"original_url"`
// UserName is only assigned from the IDP auth response, if present it
// indicates the that user has authenticated against the IDP.
UserName string `json:"user_name"`
// ExpiresAt session will be removed after this time.
ExpiresAt time.Time `json:"expires_at"`
Metadata string `json:"metadata"`
}
// SessionStore persists state of a sso session across process boundries and
// method calls by associating the state of the sign on session with a unique
// token created by the user agent (browser SPA). The lifetime of the state object
// is constrained in the backing store (Redis) so if the sso process is not completed in
// a reasonable amount of time, it automatically expires and is removed.
type SessionStore interface {
create(requestID, originalURL, x509Cert string, lifetimeSecs uint) error
Get(requestID string) (*Session, error)
Expire(requestID string) error
}
// NewSessionStore creates a SessionStore
func NewSessionStore(pool *redis.Pool) SessionStore {
return &store{pool}
}
type store struct {
pool *redis.Pool
}
func (s *store) create(requestID, originalURL, metadata string, lifetimeSecs uint) error {
if len(requestID) < 8 {
return errors.New("request id must be 8 or more characters in length")
}
conn := s.pool.Get()
defer conn.Close()
sess := Session{OriginalURL: originalURL, Metadata: metadata}
var writer bytes.Buffer
err := json.NewEncoder(&writer).Encode(sess)
if err != nil {
return err
}
_, err = conn.Do("SETEX", requestID, lifetimeSecs, writer.String())
if err != nil {
return err
}
return err
}
func (s *store) Get(requestID string) (*Session, error) {
conn := s.pool.Get()
defer conn.Close()
val, err := redis.String(conn.Do("GET", requestID))
if err != nil {
if err == redis.ErrNil {
return nil, ErrSessionNotFound
}
return nil, err
}
var sess Session
reader := bytes.NewBufferString(val)
err = json.NewDecoder(reader).Decode(&sess)
if err != nil {
return nil, err
}
return &sess, nil
}
var ErrSessionNotFound = errors.New("session not found")
func (s *store) Expire(requestID string) error {
conn := s.pool.Get()
defer conn.Close()
_, err := conn.Do("DEL", requestID)
return err
}

View file

@ -0,0 +1,55 @@
package sso
import (
"fmt"
"os"
"testing"
"time"
"github.com/garyburd/redigo/redis"
"github.com/kolide/kolide/server/pubsub"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newPool(t *testing.T) *redis.Pool {
if _, ok := os.LookupEnv("REDIS_TEST"); ok {
var (
addr = "127.0.0.1:6379"
password = ""
)
if a, ok := os.LookupEnv("REDIS_PORT_6379_TCP_ADDR"); ok {
addr = fmt.Sprintf("%s:6379", a)
}
p := pubsub.NewRedisPool(addr, password)
_, err := p.Get().Do("PING")
require.Nil(t, err)
return p
}
return nil
}
func TestSessionStore(t *testing.T) {
if _, ok := os.LookupEnv("REDIS_TEST"); !ok {
t.Skip("skipping sso session store tests")
}
p := newPool(t)
require.NotNil(t, p)
defer p.Close()
store := NewSessionStore(p)
require.NotNil(t, store)
// Create session that lives for 1 second.
err := store.create("request123", "https://originalurl.com", "some metadata", 1)
require.Nil(t, err)
sess, err := store.Get("request123")
require.Nil(t, err)
require.NotNil(t, sess)
assert.Equal(t, "https://originalurl.com", sess.OriginalURL)
assert.Equal(t, "some metadata", sess.Metadata)
// Wait a little bit more than one second, session should no longer be present.
time.Sleep(1100 * time.Millisecond)
sess, err = store.Get("request123")
assert.Equal(t, ErrSessionNotFound, err)
assert.Nil(t, sess)
}

99
server/sso/settings.go Normal file
View file

@ -0,0 +1,99 @@
package sso
import (
"encoding/xml"
"io/ioutil"
"net/http"
"time"
"github.com/pkg/errors"
dsigtypes "github.com/russellhaering/goxmldsig/types"
)
type Metadata struct {
XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntityDescriptor"`
EntityID string `xml:"entityID,attr"`
IDPSSODescriptor IDPSSODescriptor `xml:"IDPSSODescriptor"`
}
type IDPSSODescriptor struct {
XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata IDPSSODescriptor"`
KeyDescriptors []KeyDescriptor `xml:"KeyDescriptor"`
NameIDFormats []NameIDFormat `xml:"NameIDFormat"`
SingleSignOnService []SingleSignOnService `xml:"SingleSignOnService"`
Attributes []Attribute `xml:"Attribute"`
}
type KeyDescriptor struct {
XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata KeyDescriptor"`
Use string `xml:"use,attr"`
KeyInfo dsigtypes.KeyInfo `xml:"KeyInfo"`
}
type NameIDFormat struct {
XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata NameIDFormat"`
Value string `xml:",chardata"`
}
type SingleSignOnService struct {
XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata SingleSignOnService"`
Binding string `xml:"Binding,attr"`
Location string `xml:"Location,attr"`
}
const (
PasswordProtectedTransport = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
PostBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
RedirectBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
)
type Settings struct {
Metadata *Metadata
// AssertionConsumerServiceURL is the call back on the service provider which responds
// to the IDP
AssertionConsumerServiceURL string
SessionStore SessionStore
OriginalURL string
}
// ParseMetadata writes metadata xml to a struct
func ParseMetadata(metadata string) (*Metadata, error) {
var md Metadata
err := xml.Unmarshal([]byte(metadata), &md)
if err != nil {
return nil, err
}
return &md, nil
}
// GetMetadata retrieves information describing how to interact with a particular
// IDP via a remote URL. metadataURL is the location where the metadata is located
// and timeout defines how long to wait to get a response form the metadata
// server.
func GetMetadata(metadataURL string, timeout time.Duration) (*Metadata, error) {
request, err := http.NewRequest(http.MethodGet, metadataURL, nil)
if err != nil {
return nil, err
}
client := http.Client{Timeout: timeout}
resp, err := client.Do(request)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.Errorf("SAML metadata server at %s returned %s", metadataURL, resp.Status)
}
xmlData, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var md Metadata
err = xml.Unmarshal(xmlData, &md)
if err != nil {
return nil, err
}
return &md, nil
}

View file

@ -0,0 +1,75 @@
package sso
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var metadata = `<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://www.okta.com/exka4zkf6dxm8pF220h7">
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIDpDCCAoygAwIBAgIGAVtYB4c1MA0GCSqGSIb3DQEBCwUAMIGSMQswCQYDVQQGEwJVUzETMBEG
A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU
MBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi0xMzIwMzgxHDAaBgkqhkiG9w0BCQEW
DWluZm9Ab2t0YS5jb20wHhcNMTcwNDEwMTMyMTIwWhcNMjcwNDEwMTMyMjIwWjCBkjELMAkGA1UE
BhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNV
BAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtMTMyMDM4MRwwGgYJ
KoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
p+FVPm80AKh7HyTrVA05NHz8tMKIjtt0TmbRmjp6Mol+jGLYb5ILzPKQAmdh//a0hEXTTsPNw5H1
35fl5auXI1n6i8ZnoGc9/Ym7gSWKz93plbu1i3QbxhGHZmKvO66Ba7ag6z6ko6kg9A8k2UK4+5O4
T0toRUZ54YkH/ugDtfhspjlF5NjNwktL4Dj/EOel5A9I11WnHb2l3tYZkl0/viKSBOHfraPlbFUS
aG8kduQkW7+4bY18JUqDcNtQEVvlz0zm+g3WsfNM/Bhi1bxkI0aDCyZpBsXedaMv4KbZzOudx6LS
epixcLHWku5idBVRXqQTGLQdNW/P1qGQgdAeTQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBvpAp8
ExV9Wr/Q3x7fvmcNj9qnkONjTFs5k4Qhh5Ms/adq9kM7IgEeY7s6ZksC1v5nuQOAFWOWgzZS3aX3
Tgl1fvPaZFmq+wcykUPnaBFbY2awRyOeIgdNbUjgr6fvi/D8xvgunFG4TIGqfS33O/+h9bXaCMQB
EyHJq5F+u/h/L8f6CYiEY21qpl9bjL3g+li1tQTP7FnxAR/uj5cUsBp1ZdVUSyEvCWg0hJx6NQQF
3lLbb1x1Xj1Y+GKFjnNudyai660kM02xI4D5kgjz9Yp0c7UQ0Qufnq8OzpdIrVHsw4NIzJpLtO8D
rICQDchR6/cxoQCkoyf+/YTpY492MafV</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://dev-132038.oktapreview.com/app/kolidedev132038_kolide_1/exka4zkf6dxm8pF220h7/sso/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://dev-132038.oktapreview.com/app/kolidedev132038_kolide_1/exka4zkf6dxm8pF220h7/sso/saml"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>
`
func TestParseMetadata(t *testing.T) {
settings, err := ParseMetadata(metadata)
require.Nil(t, err)
assert.Equal(t, "http://www.okta.com/exka4zkf6dxm8pF220h7", settings.EntityID)
assert.Len(t, settings.IDPSSODescriptor.NameIDFormats, 2)
require.Len(t, settings.IDPSSODescriptor.KeyDescriptors, 1)
assert.True(t, settings.IDPSSODescriptor.KeyDescriptors[0].KeyInfo.X509Data.X509Certificate.Data != "")
require.Len(t, settings.IDPSSODescriptor.SingleSignOnService, 2)
assert.Equal(t, "https://dev-132038.oktapreview.com/app/kolidedev132038_kolide_1/exka4zkf6dxm8pF220h7/sso/saml",
settings.IDPSSODescriptor.SingleSignOnService[0].Location)
assert.Equal(t, "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", settings.IDPSSODescriptor.SingleSignOnService[0].Binding)
}
func TestGetMetadata(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(metadata))
}))
settings, err := GetMetadata(ts.URL, 2*time.Second)
require.Nil(t, err)
assert.Equal(t, "http://www.okta.com/exka4zkf6dxm8pF220h7", settings.EntityID)
assert.Len(t, settings.IDPSSODescriptor.NameIDFormats, 2)
require.Len(t, settings.IDPSSODescriptor.KeyDescriptors, 1)
assert.True(t, settings.IDPSSODescriptor.KeyDescriptors[0].KeyInfo.X509Data.X509Certificate.Data != "")
require.Len(t, settings.IDPSSODescriptor.SingleSignOnService, 2)
assert.Equal(t, "https://dev-132038.oktapreview.com/app/kolidedev132038_kolide_1/exka4zkf6dxm8pF220h7/sso/saml",
settings.IDPSSODescriptor.SingleSignOnService[0].Location)
assert.Equal(t, "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", settings.IDPSSODescriptor.SingleSignOnService[0].Binding)
}

267
server/sso/types.go Normal file
View file

@ -0,0 +1,267 @@
package sso
import "encoding/xml"
// AuthnRequest contains information needed to request authorization from
// an IDP
// See http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf Section 3.4.1
type AuthnRequest struct {
XMLName xml.Name
SAMLP string `xml:"xmlns:samlp,attr"`
SAML string `xml:"xmlns:saml,attr"`
SAMLSIG string `xml:"xmlns:samlsig,attr,omitempty"`
ID string `xml:"ID,attr"`
Version string `xml:"Version,attr"`
ProtocolBinding string `xml:"ProtocolBinding,attr"`
AssertionConsumerServiceURL string `xml:"AssertionConsumerServiceURL,attr"`
Destination string `xml:"Destination,attr"`
IssueInstant string `xml:"IssueInstant,attr"`
ProviderName string `xml:"ProviderName,attr"`
Issuer Issuer `xml:"Issuer"`
NameIDPolicy *NameIDPolicy `xml:"NameIDPolicy,omitempty"`
RequestedAuthnContext *RequestedAuthnContext `xml:"RequestedAuthnContext,omitempty"`
Signature *Signature `xml:"Signature,omitempty"`
originalString string
}
// Response is submitted to the service provider (Kolide) from the IDP via a callback.
// It will contain information about a authenticated user that can in turn
// be used to generate a session token.
// See http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf Section 3.3.3.
type Response struct {
XMLName xml.Name
SAMLP string `xml:"xmlns:samlp,attr"`
SAML string `xml:"xmlns:saml,attr"`
SAMLSIG string `xml:"xmlns:samlsig,attr"`
Destination string `xml:"Destination,attr"`
ID string `xml:"ID,attr"`
Version string `xml:"Version,attr"`
IssueInstant string `xml:"IssueInstant,attr"`
InResponseTo string `xml:"InResponseTo,attr"`
Assertion Assertion `xml:"Assertion"`
Signature Signature `xml:"Signature"`
Issuer Issuer `xml:"Issuer"`
Status Status `xml:"Status"`
originalString string
}
type Issuer struct {
XMLName xml.Name
Url string `xml:",innerxml"`
}
type NameIDPolicy struct {
XMLName xml.Name
AllowCreate bool `xml:"AllowCreate,attr"`
Format string `xml:"Format,attr"`
}
type RequestedAuthnContext struct {
XMLName xml.Name
SAMLP string `xml:"xmlns:samlp,attr"`
Comparison string `xml:"Comparison,attr"`
AuthnContextClassRef AuthnContextClassRef `xml:"AuthnContextClassRef"`
}
type AuthnContextClassRef struct {
XMLName xml.Name
SAML string `xml:"xmlns:saml,attr"`
Transport string `xml:",innerxml"`
}
type Signature struct {
XMLName xml.Name
Id string `xml:"Id,attr"`
SignedInfo SignedInfo
SignatureValue SignatureValue
KeyInfo KeyInfo
}
type SignedInfo struct {
XMLName xml.Name
CanonicalizationMethod CanonicalizationMethod
SignatureMethod SignatureMethod
SamlsigReference SamlsigReference
}
type SignatureValue struct {
XMLName xml.Name
Value string `xml:",innerxml"`
}
type KeyInfo struct {
XMLName xml.Name
X509Data X509Data `xml:",innerxml"`
}
type CanonicalizationMethod struct {
XMLName xml.Name
Algorithm string `xml:"Algorithm,attr"`
}
type SignatureMethod struct {
XMLName xml.Name
Algorithm string `xml:"Algorithm,attr"`
}
type SamlsigReference struct {
XMLName xml.Name
URI string `xml:"URI,attr"`
Transforms Transforms `xml:",innerxml"`
DigestMethod DigestMethod `xml:",innerxml"`
DigestValue DigestValue `xml:",innerxml"`
}
type X509Data struct {
XMLName xml.Name
X509Certificate X509Certificate `xml:",innerxml"`
}
type Transforms struct {
XMLName xml.Name
Transform Transform
}
type DigestMethod struct {
XMLName xml.Name
Algorithm string `xml:"Algorithm,attr"`
}
type DigestValue struct {
XMLName xml.Name
}
type X509Certificate struct {
XMLName xml.Name
Cert string `xml:",innerxml"`
}
type Transform struct {
XMLName xml.Name
Algorithm string `xml:"Algorithm,attr"`
}
type EntityDescriptor struct {
XMLName xml.Name
DS string `xml:"xmlns:ds,attr"`
XMLNS string `xml:"xmlns,attr"`
MD string `xml:"xmlns:md,attr"`
EntityId string `xml:"entityID,attr"`
Extensions Extensions `xml:"Extensions"`
SPSSODescriptor SPSSODescriptor `xml:"SPSSODescriptor"`
}
type Extensions struct {
XMLName xml.Name
Alg string `xml:"xmlns:alg,attr"`
MDAttr string `xml:"xmlns:mdattr,attr"`
MDRPI string `xml:"xmlns:mdrpi,attr"`
EntityAttributes string `xml:"EntityAttributes"`
}
type SPSSODescriptor struct {
XMLName xml.Name
ProtocolSupportEnumeration string `xml:"protocolSupportEnumeration,attr"`
SigningKeyDescriptor KeyDescriptor
EncryptionKeyDescriptor KeyDescriptor
AssertionConsumerServices []AssertionConsumerService
}
type EntityAttributes struct {
XMLName xml.Name
SAML string `xml:"xmlns:saml,attr"`
EntityAttributes []Attribute `xml:"Attribute"` // should be array??
}
type SPSSODescriptors struct {
}
type SingleLogoutService struct {
Binding string `xml:"Binding,attr"`
Location string `xml:"Location,attr"`
}
type AssertionConsumerService struct {
XMLName xml.Name
Binding string `xml:"Binding,attr"`
Location string `xml:"Location,attr"`
Index string `xml:"index,attr"`
}
type Assertion struct {
XMLName xml.Name
ID string `xml:"ID,attr"`
Version string `xml:"Version,attr"`
XS string `xml:"xmlns:xs,attr"`
XSI string `xml:"xmlns:xsi,attr"`
SAML string `xml:"saml,attr"`
IssueInstant string `xml:"IssueInstant,attr"`
Issuer Issuer `xml:"Issuer"`
Subject Subject
Conditions Conditions
AttributeStatement AttributeStatement
}
type Conditions struct {
XMLName xml.Name
NotBefore string `xml:",attr"`
NotOnOrAfter string `xml:",attr"`
}
type Subject struct {
XMLName xml.Name
NameID NameID
SubjectConfirmation SubjectConfirmation
}
type SubjectConfirmation struct {
XMLName xml.Name
Method string `xml:",attr"`
SubjectConfirmationData SubjectConfirmationData
}
type Status struct {
XMLName xml.Name
StatusCode StatusCode `xml:"StatusCode"`
}
type SubjectConfirmationData struct {
InResponseTo string `xml:",attr"`
NotOnOrAfter string `xml:",attr"`
Recipient string `xml:",attr"`
}
type NameID struct {
XMLName xml.Name
Format string `xml:",attr"`
Value string `xml:",innerxml"`
}
type StatusCode struct {
XMLName xml.Name
Value string `xml:",attr"`
}
type AttributeValue struct {
XMLName xml.Name
Type string `xml:"xsi:type,attr"`
Value string `xml:",innerxml"`
}
type Attribute struct {
XMLName xml.Name
Name string `xml:",attr"`
FriendlyName string `xml:",attr"`
NameFormat string `xml:",attr"`
AttributeValues []AttributeValue `xml:"AttributeValue"`
}
type AttributeStatement struct {
XMLName xml.Name
Attributes []Attribute `xml:"Attribute"`
}

168
server/sso/validate.go Normal file
View file

@ -0,0 +1,168 @@
package sso
import (
"crypto/x509"
"encoding/base64"
"encoding/xml"
"time"
"github.com/beevik/etree"
"github.com/kolide/kolide/server/kolide"
"github.com/pkg/errors"
gosamltypes "github.com/russellhaering/gosaml2/types"
dsig "github.com/russellhaering/goxmldsig"
"github.com/russellhaering/goxmldsig/etreeutils"
)
type Validator interface {
ValidateSignature(auth kolide.Auth) (kolide.Auth, error)
ValidateResponse(auth kolide.Auth) error
}
type validator struct {
context *dsig.ValidationContext
clock *dsig.Clock
metadata gosamltypes.EntityDescriptor
}
func Clock(clock *dsig.Clock) func(v *validator) {
return func(v *validator) {
v.clock = clock
}
}
// NewValidator is used to validate the response to an auth request.
// metadata is from the IDP.
func NewValidator(metadata string, opts ...func(v *validator)) (Validator, error) {
var v validator
err := xml.Unmarshal([]byte(metadata), &v.metadata)
if err != nil {
return nil, errors.Wrap(err, "unmarshalling metadata")
}
var idpCertStore dsig.MemoryX509CertificateStore
for _, key := range v.metadata.IDPSSODescriptor.KeyDescriptors {
certData, err := base64.StdEncoding.DecodeString(key.KeyInfo.X509Data.X509Certificate.Data)
if err != nil {
return nil, errors.Wrap(err, "decoding idp x509 cert")
}
cert, err := x509.ParseCertificate(certData)
if err != nil {
return nil, errors.Wrap(err, "parsing idp x509 cert")
}
idpCertStore.Roots = append(idpCertStore.Roots, cert)
}
for _, opt := range opts {
opt(&v)
}
if v.clock == nil {
v.clock = dsig.NewRealClock()
}
v.context = dsig.NewDefaultValidationContext(&idpCertStore)
v.context.Clock = v.clock
return &v, nil
}
func (v *validator) ValidateResponse(auth kolide.Auth) error {
info := auth.(*resp)
// make sure response is current
onOrAfter, err := time.Parse(time.RFC3339, info.response.Assertion.Conditions.NotOnOrAfter)
if err != nil {
return errors.Wrap(err, "missing timestamp from condition")
}
notBefore, err := time.Parse(time.RFC3339, info.response.Assertion.Conditions.NotBefore)
if err != nil {
return errors.Wrap(err, "missing timestamp from condition")
}
currentTime := v.clock.Now()
if currentTime.After(onOrAfter) {
return errors.New("response expired")
}
if currentTime.Before(notBefore) {
return errors.New("response too early")
}
if auth.UserID() == "" {
return errors.New("missing user id")
}
return nil
}
func (v *validator) ValidateSignature(auth kolide.Auth) (kolide.Auth, error) {
info := auth.(*resp)
status, err := info.status()
if err != nil {
return nil, errors.New("missing or malformed response")
}
if status != Success {
return nil, errors.Errorf("response status %s", info.statusDescription())
}
decoded, err := base64.StdEncoding.DecodeString(info.rawResponse())
if err != nil {
return nil, errors.Wrap(err, "based64 decoding response")
}
doc := etree.NewDocument()
err = doc.ReadFromBytes(decoded)
if err != nil || doc.Root() == nil {
return nil, errors.Wrap(err, "parsing xml response")
}
elt := doc.Root()
signed, err := v.validateSignature(elt)
if err != nil {
return nil, errors.Wrap(err, "signing verification failed")
}
// We've verified that the response hasn't been tampered with at this point
signedDoc := etree.NewDocument()
signedDoc.SetRoot(signed)
buffer, err := doc.WriteToBytes()
if err != nil {
return nil, errors.Wrap(err, "creating signed doc buffer")
}
var response Response
err = xml.Unmarshal(buffer, &response)
if err != nil {
return nil, errors.Wrap(err, "unmarshalling signed doc")
}
info.setResponse(&response)
return info, nil
}
func (v *validator) validateSignature(elt *etree.Element) (*etree.Element, error) {
validated, err := v.context.Validate(elt)
if err == nil {
// If entire doc is signed, success, we're done.
return validated, nil
}
if err == dsig.ErrMissingSignature {
// If entire document is not signed find signed assertions, remove assertions
// that are not signed.
err = v.validateAssertionSignature(elt)
if err != nil {
return nil, err
}
return elt, nil
}
return nil, err
}
func (v *validator) validateAssertionSignature(elt *etree.Element) error {
validateAssertion := func(ctx etreeutils.NSContext, unverified *etree.Element) error {
if unverified.Parent() != elt {
return errors.Errorf("assertion with unexpected parent: %s", unverified.Parent().Tag)
}
// Remove assertions that are not signed.
detached, err := etreeutils.NSDetatch(ctx, unverified)
if err != nil {
return err
}
signed, err := v.context.Validate(detached)
if err != nil {
return err
}
elt.RemoveChild(unverified)
elt.AddChild(signed)
return nil
}
return etreeutils.NSFindIterate(elt, "urn:oasis:names:tc:SAML:2.0:assertion", "Assertion", validateAssertion)
}

107
server/sso/validate_test.go Normal file

File diff suppressed because one or more lines are too long

108
tools/app/authtest.html Normal file
View file

@ -0,0 +1,108 @@
<html>
<head>
<style>
div.columns { width: 900px; padding-top: 10px; padding-bottom: 10px; }
div.columns div { width: 300px; float: left; }
div.clear { clear: both; }
div.content { width: 900px; padding-top: 10px; padding-bottom: 10px; }
</style>
<script
src="https://code.jquery.com/jquery-3.2.1.js"
integrity="sha256-DZAnKJ/6XZ9si04Hgrsxu/8s717jcIzLy3oi35EouyE="
crossorigin="anonymous">
</script>
<script>
$(document).ready(function(){
// User agent handling for SSO
// Check for existing session token indicating user has already started SSO process.
// If the token exists, it is used to fetch the same user info/token as the
// normal login process, albeit via the different SSO login endpoint. Note the session token only
// persists for a few minutes on the server side, and, when we're done
// we always delete the token in the user agent. We use the session token
// to keep track of state from the user agent (Kolide SPA), the service provider
// (Kolide back end), to the identity provider (IDP) and back.
var sessionToken = localStorage.getItem("KOLIDE::auth_token")
if( sessionToken != null ) {
console.log("user should be authenticated, fetching user with token " + sessionToken);
$.ajax({
type: "GET",
url: "https://localhost:8080/api/v1/kolide/me",
headers: {"Authorization": "Bearer " + sessionToken},
contentType: "text/plain;",
dataType: "json",
success: function(data) {
// We've successfully created a login session with a token that
// we can use in subsequent api calls to Kolide.
console.log("sso login succeeded " + data);
$("#displayarea").empty();
$("#displayarea").append(
"<h3>Authentication Succeeded</h3>" +
"<p>Token: " + localStorage.getItem("KOLIDE::auth_token").substring(0, 16) + "..." + "</p>" +
"<p>User: " + data.user.email + "</p>"
);
// print user stuff
},
error: function(err) {
console.log("sso login failed " + data);
$("#displayarea").empty();
$("#displayarea").append(
"<h3>Auth Failed</h3>"
);
}
});
console.log("removing token " + localStorage.getItem("ssoSession"));
localStorage.removeItem("ssoSession");
}
// Single sign on invocation. User agent chooses single sign on for a particular
// IDP trigger the following post.
$(".clicker").click(function(e){
e.preventDefault();
$.ajax({
type: "POST",
url: "https://localhost:8080/api/v1/kolide/sso",
data: JSON.stringify(
{
// supply the url of the resource user was trying to access when
// prompted for login
relay_url: $("#relay").val(),
}
),
contentType: "text/plain;",
dataType: "json",
success: function(data){
console.log(data);
// on success we redirect to IDP URL which is in response
window.location.href = data.url;
},
error: function(errMsg) {console.log(errMsg);}
});
});
});
</script>
</head>
<body>
<h3>Single Sign On Test Page</h3>
<div class="content">
This page is used to test single sign on identity providers. The <strong>Relay URL</strong> field contains
the URL of the Kolide resource to invoke after authentication with the IDP. It defaults to this page.
Click <a class='clicker' href='#'>Request Authorization</a>
to trigger the authorization process. The browser's javascript console may contain useful
debugging information. The <a href="https://addons.mozilla.org/en-US/firefox/addon/saml-tracer/">SAML Tracer</a> add-on
for Firefox is also useful in diagnosing problems with a particular identity provider.
</div>
<div class="columns">
<div>
Relay URL:
</div>
<div>
<input type="text" name="relay" id="relay" value="/test"/>
</div>
</div>
<div class="clear"></div>
<p><a class='clicker' href='#'>Request Authorization</a></p>
<div id="displayarea"></div>
</body>
</html>