mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 00:49:03 +00:00
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:
parent
5a69cf1530
commit
368b9d774c
50 changed files with 1946 additions and 428 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
21
cli/serve.go
21
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
|
||||
|
|
|
|||
4
docs/third-party/licenses.md
vendored
4
docs/third-party/licenses.md
vendored
|
|
@ -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
19
glide.lock
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -80,5 +80,4 @@ var testFunctions = [...]func(*testing.T, kolide.Datastore){
|
|||
testCountHostsInTargets,
|
||||
testHostStatus,
|
||||
testResetOptions,
|
||||
testIdentityProvider,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ type Datastore interface {
|
|||
FileIntegrityMonitoringStore
|
||||
YARAStore
|
||||
LicenseStore
|
||||
IdentityProviderStore
|
||||
Name() string
|
||||
Drop() error
|
||||
// MigrateTables creates and migrates the table schemas
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 = "********"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
50
server/service/validation_app_config.go
Normal file
50
server/service/validation_app_config.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
server/service/validation_app_config_test.go
Normal file
47
server/service/validation_app_config_test.go
Normal 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)
|
||||
}
|
||||
35
server/service/validation_sessions.go
Normal file
35
server/service/validation_sessions.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
136
server/sso/authorization_request.go
Normal file
136
server/sso/authorization_request.go
Normal 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
|
||||
}
|
||||
18
server/sso/authorization_request_test.go
Normal file
18
server/sso/authorization_request_test.go
Normal 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)
|
||||
}
|
||||
132
server/sso/authorization_response.go
Normal file
132
server/sso/authorization_response.go
Normal 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
|
||||
}
|
||||
21
server/sso/authorization_response_test.go
Normal file
21
server/sso/authorization_response_test.go
Normal file
File diff suppressed because one or more lines are too long
90
server/sso/session_store.go
Normal file
90
server/sso/session_store.go
Normal 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
|
||||
}
|
||||
55
server/sso/session_store_test.go
Normal file
55
server/sso/session_store_test.go
Normal 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
99
server/sso/settings.go
Normal 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
|
||||
}
|
||||
75
server/sso/settings_test.go
Normal file
75
server/sso/settings_test.go
Normal 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
267
server/sso/types.go
Normal 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
168
server/sso/validate.go
Normal 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
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
108
tools/app/authtest.html
Normal 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>
|
||||
Loading…
Reference in a new issue