From bb1043181ce203a0657ffadf427a09f261e87392 Mon Sep 17 00:00:00 2001 From: Peter Grlica Date: Wed, 28 Apr 2021 08:48:25 +0200 Subject: [PATCH] Added SAML service --- app/boot_levels.go | 7 +- auth/auth.go | 99 ++++++++++++- auth/external/auth_handler.go | 76 ++++++++++ auth/handlers/handle_external.go | 24 ++-- auth/handlers/handle_saml.go | 23 +++ auth/handlers/handler.go | 10 +- auth/handlers/links.go | 10 ++ auth/handlers/mock_test.go | 5 +- auth/handlers/routes.go | 9 +- auth/request/auth_user.go | 1 + auth/saml/cert.go | 122 ++++++++++++++++ auth/saml/cert_test.go | 90 ++++++++++++ auth/saml/idp.go | 9 ++ auth/saml/saml.go | 54 +++++++ auth/saml/sp.go | 134 ++++++++++++++++++ auth/settings/settings.go | 21 +++ system/service/auth.go | 13 +- system/types/app_settings.go | 22 +++ system/types/auth.go | 6 + .../crewjam/saml/samlsp/middleware.go | 4 - 20 files changed, 710 insertions(+), 29 deletions(-) create mode 100644 auth/external/auth_handler.go create mode 100644 auth/handlers/handle_saml.go create mode 100644 auth/saml/cert.go create mode 100644 auth/saml/cert_test.go create mode 100644 auth/saml/idp.go create mode 100644 auth/saml/saml.go create mode 100644 auth/saml/sp.go diff --git a/app/boot_levels.go b/app/boot_levels.go index 66307a702..6b8f11736 100644 --- a/app/boot_levels.go +++ b/app/boot_levels.go @@ -4,9 +4,11 @@ import ( "context" "crypto/tls" "fmt" - authHandlers "github.com/cortezaproject/corteza-server/auth/handlers" "strings" + authHandlers "github.com/cortezaproject/corteza-server/auth/handlers" + "github.com/cortezaproject/corteza-server/auth/saml" + authService "github.com/cortezaproject/corteza-server/auth" authSettings "github.com/cortezaproject/corteza-server/auth/settings" autService "github.com/cortezaproject/corteza-server/automation/service" @@ -482,5 +484,8 @@ func updateAuthSettings(svc authServicer, current *types.AppSettings) { as.MultiFactor.EmailOTP.Enabled = cas.MultiFactor.EmailOTP.Enabled as.MultiFactor.EmailOTP.Enforced = cas.MultiFactor.EmailOTP.Enforced + // SAML + saml.UpdateSettings(current, as) + svc.UpdateSettings(as) } diff --git a/auth/auth.go b/auth/auth.go index 1386aed37..f2c67195b 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -4,11 +4,20 @@ import ( "context" "embed" "fmt" + "html/template" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + "github.com/Masterminds/sprig" "github.com/cortezaproject/corteza-server/auth/external" "github.com/cortezaproject/corteza-server/auth/handlers" "github.com/cortezaproject/corteza-server/auth/oauth2" "github.com/cortezaproject/corteza-server/auth/request" + "github.com/cortezaproject/corteza-server/auth/saml" "github.com/cortezaproject/corteza-server/auth/settings" "github.com/cortezaproject/corteza-server/pkg/actionlog" "github.com/cortezaproject/corteza-server/pkg/auth" @@ -20,12 +29,6 @@ import ( "github.com/go-chi/chi" oauth2def "github.com/go-oauth2/oauth2/v4" "go.uber.org/zap" - "html/template" - "net/http" - "os" - "strconv" - "strings" - "time" ) type ( @@ -227,6 +230,60 @@ func New(ctx context.Context, log *zap.Logger, s store.Storer, opt options.AuthO return } +// LoadSamlService takes care of certificate preloading, fetching of the +// IDP metadata once the auth settings are loaded and registers the +// SAML middleware +func (svc *service) LoadSamlService(ctx context.Context, s *settings.Settings) (srvc *saml.SamlSPService, err error) { + links := handlers.GetLinks() + + certManager := saml.NewCertManager(&saml.CertStoreLoader{Storer: svc.store}) + + cert, err := certManager.Parse([]byte(s.Saml.Cert), []byte(s.Saml.Key)) + if err != nil { + return + } + + idpUrl, err := url.Parse(s.Saml.IDP.URL) + if err != nil { + return + } + + // idp metadata needs to be loaded before + // the internal samlsp package + md, err := saml.FetchIDPMetadata(ctx, *idpUrl) + ru, err := url.Parse(svc.opt.BaseURL) + + rootURL := &url.URL{ + Scheme: ru.Scheme, + User: ru.User, + Host: ru.Host, + } + + if err != nil { + return + } + + srvc, err = saml.NewSamlSPService(saml.SamlSPArgs{ + AcsURL: links.SamlCallback, + MetaURL: links.SamlMetadata, + SloURL: links.SamlLogout, + + IdpURL: *idpUrl, + Host: *rootURL, + + Cert: cert, + IdpMeta: md, + + IdentityPayload: saml.IdpIdentityPayload{ + Name: s.Saml.IDP.IdentName, + Handle: s.Saml.IDP.IdentHandle, + Identifier: s.Saml.IDP.IdentIdentifier, + }, + }) + + return +} + func (svc *service) UpdateSettings(s *settings.Settings) { if svc.settings.LocalEnabled != s.LocalEnabled { svc.log.Debug("setting changed", zap.Bool("localEnabled", s.LocalEnabled)) @@ -252,6 +309,36 @@ func (svc *service) UpdateSettings(s *settings.Settings) { svc.log.Debug("setting changed", zap.Any("mfa", s.MultiFactor)) } + if svc.settings.Saml != s.Saml { + var ( + log = svc.log.Named("saml") + ss *saml.SamlSPService + err error + ) + + switch true { + case s.Saml.Cert == "", s.Saml.Key == "": + log.Warn("certificate private/public keys empty (see 'auth.external.saml' settings)") + break + + case s.Saml.IDP.URL == "": + log.Warn("could not get IDP url (see 'auth.external.saml.idp')") + break + + default: + ss, err = svc.LoadSamlService(context.Background(), s) + + if err != nil { + log.Warn("could not reload service", zap.Error(err)) + } + } + + if ss != nil { + log.Debug("settings changed, reloading") + svc.handlers.SamlSPService = *ss + } + } + if len(svc.settings.Providers) != len(s.Providers) { svc.log.Debug("setting changed", zap.Int("providers", len(s.Providers))) external.SetupGothProviders(svc.opt.ExternalRedirectURL, s.Providers...) diff --git a/auth/external/auth_handler.go b/auth/external/auth_handler.go new file mode 100644 index 000000000..a476dc989 --- /dev/null +++ b/auth/external/auth_handler.go @@ -0,0 +1,76 @@ +package external + +import ( + "net/http" + + "github.com/cortezaproject/corteza-server/auth/saml" + "github.com/cortezaproject/corteza-server/system/types" + "github.com/crewjam/saml/samlsp" + "github.com/markbates/goth/gothic" +) + +type ( + ExternalAuthHandler interface { + BeginUserAuth(http.ResponseWriter, *http.Request) + CompleteUserAuth(http.ResponseWriter, *http.Request) (u *types.ExternalAuthUser, err error) + } + + externalSamlAuthHandler struct { + service saml.SamlSPService + } + + externalDefaultAuthHandler struct{} +) + +func (eh *externalDefaultAuthHandler) BeginUserAuth(w http.ResponseWriter, r *http.Request) { + gothic.BeginAuthHandler(w, r) +} + +func (eh *externalDefaultAuthHandler) CompleteUserAuth(w http.ResponseWriter, r *http.Request) (u *types.ExternalAuthUser, err error) { + gu, err := gothic.CompleteUserAuth(w, r) + u = &types.ExternalAuthUser{gu} + + return +} + +func (eh *externalSamlAuthHandler) BeginUserAuth(w http.ResponseWriter, r *http.Request) { + _, err := eh.service.Handler().Session.GetSession(r) + + if err == samlsp.ErrNoSession { + eh.service.Handler().HandleStartAuthFlow(w, r) + } +} + +func (eh *externalSamlAuthHandler) CompleteUserAuth(w http.ResponseWriter, r *http.Request) (u *types.ExternalAuthUser, err error) { + var ( + session samlsp.Session + ) + + if session, err = eh.service.Handler().Session.GetSession(r); err != nil { + return + } + + if session != nil { + s := (session.(samlsp.JWTSessionClaims)).GetAttributes() + + u = &types.ExternalAuthUser{} + u.Provider = "saml" + + // get identifier for use with Corteza (email) + u.Email = eh.service.GuessIdentifier(s) + u.Name = s.Get(eh.service.IDPUserMeta.Name) + u.NickName = s.Get(eh.service.IDPUserMeta.Handle) + } + + return +} + +func NewSamlExternalHandler(s saml.SamlSPService) *externalSamlAuthHandler { + return &externalSamlAuthHandler{ + service: s, + } +} + +func NewDefaultExternalHandler() *externalDefaultAuthHandler { + return &externalDefaultAuthHandler{} +} diff --git a/auth/handlers/handle_external.go b/auth/handlers/handle_external.go index a128a7662..c8dcde837 100644 --- a/auth/handlers/handle_external.go +++ b/auth/handlers/handle_external.go @@ -3,15 +3,15 @@ package handlers import ( "context" "fmt" + "net/http" + "strings" + + "github.com/cortezaproject/corteza-server/auth/external" "github.com/cortezaproject/corteza-server/auth/request" "github.com/cortezaproject/corteza-server/pkg/api" "github.com/cortezaproject/corteza-server/system/types" "github.com/go-chi/chi" - "github.com/markbates/goth" - "github.com/markbates/goth/gothic" "go.uber.org/zap" - "net/http" - "strings" ) func copyProviderToContext(r *http.Request) *http.Request { @@ -22,25 +22,25 @@ func (h AuthHandlers) externalInit(w http.ResponseWriter, r *http.Request) { r = copyProviderToContext(r) h.Log.Info("starting external authentication flow") - gothic.BeginAuthHandler(w, r) + beginUserAuth(w, r, external.NewDefaultExternalHandler()) } func (h AuthHandlers) externalCallback(w http.ResponseWriter, r *http.Request) { r = copyProviderToContext(r) h.Log.Info("external authentication callback") - if user, err := gothic.CompleteUserAuth(w, r); err != nil { + if user, err := completeUserAuth(w, r, external.NewDefaultExternalHandler()); err != nil { h.Log.Error("failed to complete user auth", zap.Error(err)) h.handleFailedExternalAuth(w, r, err) } else { - h.handleSuccessfulExternalAuth(w, r, user) + h.handleSuccessfulExternalAuth(w, r, *user) } } // Handles authentication via external auth providers of // unknown an user + appending authentication on external providers // to a current user -func (h AuthHandlers) handleSuccessfulExternalAuth(w http.ResponseWriter, r *http.Request, cred goth.User) { +func (h AuthHandlers) handleSuccessfulExternalAuth(w http.ResponseWriter, r *http.Request, cred types.ExternalAuthUser) { var ( user *types.User err error @@ -91,3 +91,11 @@ func (h AuthHandlers) handleFailedExternalAuth(w http.ResponseWriter, r *http.Re fmt.Fprintf(w, "SSO Error: %v", err.Error()) w.WriteHeader(http.StatusOK) } + +func beginUserAuth(w http.ResponseWriter, r *http.Request, eh external.ExternalAuthHandler) { + eh.BeginUserAuth(w, r) +} + +func completeUserAuth(w http.ResponseWriter, r *http.Request, eh external.ExternalAuthHandler) (u *types.ExternalAuthUser, err error) { + return eh.CompleteUserAuth(w, r) +} diff --git a/auth/handlers/handle_saml.go b/auth/handlers/handle_saml.go new file mode 100644 index 000000000..cca5fba39 --- /dev/null +++ b/auth/handlers/handle_saml.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "net/http" + + "github.com/cortezaproject/corteza-server/auth/external" + "go.uber.org/zap" +) + +func (h AuthHandlers) samlInit(w http.ResponseWriter, r *http.Request) { + r = copyProviderToContext(r) + h.Log.Info("starting saml authentication flow") + + ex := external.NewSamlExternalHandler(h.SamlSPService) + beginUserAuth(w, r, ex) + + if user, err := completeUserAuth(w, r, ex); err != nil { + h.Log.Error("failed to complete user auth", zap.Error(err)) + h.handleFailedExternalAuth(w, r, err) + } else { + h.handleSuccessfulExternalAuth(w, r, *user) + } +} diff --git a/auth/handlers/handler.go b/auth/handlers/handler.go index 0d281050e..4818eb3cd 100644 --- a/auth/handlers/handler.go +++ b/auth/handlers/handler.go @@ -11,6 +11,7 @@ import ( "github.com/cortezaproject/corteza-server/auth/external" "github.com/cortezaproject/corteza-server/auth/request" + "github.com/cortezaproject/corteza-server/auth/saml" "github.com/cortezaproject/corteza-server/auth/settings" "github.com/cortezaproject/corteza-server/pkg/auth" "github.com/cortezaproject/corteza-server/pkg/options" @@ -19,13 +20,12 @@ import ( "github.com/go-oauth2/oauth2/v4/server" "github.com/gorilla/csrf" "github.com/gorilla/sessions" - "github.com/markbates/goth" "go.uber.org/zap" ) type ( authService interface { - External(ctx context.Context, profile goth.User) (u *types.User, err error) + External(ctx context.Context, profile types.ExternalAuthUser) (u *types.User, err error) InternalSignUp(ctx context.Context, input *types.User, password string) (u *types.User, err error) InternalLogin(ctx context.Context, email string, password string) (u *types.User, err error) SetPassword(ctx context.Context, userID uint64, password string) (err error) @@ -97,6 +97,7 @@ type ( DefaultClient *types.AuthClient Opt options.AuthOpt Settings *settings.Settings + SamlSPService saml.SamlSPService } handlerFn func(req *request.AuthReq) error @@ -276,6 +277,11 @@ func (h *AuthHandlers) enrichTmplData(req *request.AuthReq) interface{} { sort.Sort(providers) var pp = make([]provider, 0, len(providers)) + + if h.Settings.Saml.Enabled { + pp = append(pp, provider(saml.TemplateProvider(h.Settings.Saml.IDP.URL, h.Settings.Saml.IDP.Name))) + } + for i := range providers { if !providers[i].Enabled { continue diff --git a/auth/handlers/links.go b/auth/handlers/links.go index d29a4f638..3f6953582 100644 --- a/auth/handlers/links.go +++ b/auth/handlers/links.go @@ -34,6 +34,11 @@ type ( External, + SamlInit, + SamlCallback, + SamlMetadata, + SamlLogout, + Assets string } ) @@ -71,6 +76,11 @@ func GetLinks() Links { External: b + "auth/external", + SamlInit: b + "auth/external/saml/init", + SamlCallback: b + "auth/external/saml/callback", + SamlMetadata: b + "auth/external/saml/metadata", + SamlLogout: b + "auth/external/saml/slo", + Assets: b + "auth/assets/public", } } diff --git a/auth/handlers/mock_test.go b/auth/handlers/mock_test.go index fb3d96d66..39ebc2aab 100644 --- a/auth/handlers/mock_test.go +++ b/auth/handlers/mock_test.go @@ -15,7 +15,6 @@ import ( "github.com/go-oauth2/oauth2/v4" "github.com/go-oauth2/oauth2/v4/server" "github.com/gorilla/sessions" - "github.com/markbates/goth" "go.uber.org/zap" ) @@ -81,7 +80,7 @@ type ( } authServiceMocked struct { - external func(context.Context, goth.User) (u *types.User, err error) + external func(context.Context, types.ExternalAuthUser) (u *types.User, err error) internalSignUp func(context.Context, *types.User, string) (u *types.User, err error) internalLogin func(context.Context, string, string) (u *types.User, err error) setPassword func(context.Context, uint64, string) (err error) @@ -110,7 +109,7 @@ func (u userServiceMocked) Update(ctx context.Context, user *types.User) (*types // // Mocking authService // -func (s authServiceMocked) External(ctx context.Context, profile goth.User) (u *types.User, err error) { +func (s authServiceMocked) External(ctx context.Context, profile types.ExternalAuthUser) (u *types.User, err error) { return s.external(ctx, profile) } diff --git a/auth/handlers/routes.go b/auth/handlers/routes.go index f2031a7f5..b49b0d0e9 100644 --- a/auth/handlers/routes.go +++ b/auth/handlers/routes.go @@ -1,12 +1,13 @@ package handlers import ( + "net/http" + "github.com/cortezaproject/corteza-server/auth/request" "github.com/cortezaproject/corteza-server/pkg/actionlog" "github.com/go-chi/chi" "github.com/go-chi/httprate" "github.com/gorilla/csrf" - "net/http" ) func (h *AuthHandlers) MountHttpRoutes(r chi.Router) { @@ -94,6 +95,12 @@ func (h *AuthHandlers) MountHttpRoutes(r chi.Router) { r.Post(tbp(l.OAuth2DefaultClient), h.handle(h.oauth2authorizeDefaultClientProc)) }) + r.Group(func(r chi.Router) { + r.Handle(tbp(l.SamlMetadata), h.SamlSPService) + r.Handle(tbp(l.SamlCallback), h.SamlSPService) + r.HandleFunc(tbp(l.SamlInit), h.samlInit) + }) + r.Route(tbp(l.External)+"/{provider}", func(r chi.Router) { // External provider r.Get("/", h.externalInit) diff --git a/auth/request/auth_user.go b/auth/request/auth_user.go index e3ddec52c..af1dd1782 100644 --- a/auth/request/auth_user.go +++ b/auth/request/auth_user.go @@ -57,6 +57,7 @@ func NewAuthUser(s *settings.Settings, u *types.User, perm bool, permLifetime ti User: u, PermSession: perm, PermLifetime: permLifetime, + MFAStatus: make(map[authType]authStatus), } au.set(s, u) diff --git a/auth/saml/cert.go b/auth/saml/cert.go new file mode 100644 index 000000000..cd2666d0e --- /dev/null +++ b/auth/saml/cert.go @@ -0,0 +1,122 @@ +package saml + +import ( + "context" + "crypto/tls" + "fmt" + "io/ioutil" + + "github.com/cortezaproject/corteza-server/system/types" +) + +const settingsPrefix = "auth.external.providers.saml" + +type ( + Cert struct { + Cert []byte + Key []byte + } + + certManager struct { + loader CertLoader + } + + CertLoader interface { + Load(ctx context.Context) (*Cert, error) + Key(ctx context.Context) ([]byte, error) + Cert(ctx context.Context) ([]byte, error) + } + + CertStoreLoader struct { + Storer storer + } + + CertFsLoader struct { + KeyFile string + CertFile string + } + + storer interface { + SearchSettings(ctx context.Context, f types.SettingsFilter) (types.SettingValueSet, types.SettingsFilter, error) + } +) + +func (cm *certManager) Load(ctx context.Context) (cc *Cert, err error) { + return cm.loader.Load(ctx) +} + +func (cm *certManager) Parse(cert []byte, key []byte) (tls.Certificate, error) { + return tls.X509KeyPair(cert, key) +} + +func (l *CertStoreLoader) Cert(ctx context.Context) (c []byte, err error) { + return l.get(ctx, "cert") +} + +func (l *CertStoreLoader) Key(ctx context.Context) ([]byte, error) { + return l.get(ctx, "key") +} + +func (l *CertStoreLoader) get(ctx context.Context, key string) (c []byte, err error) { + var s types.SettingValueSet + + if s, _, err = l.Storer.SearchSettings(ctx, types.SettingsFilter{Prefix: settingsPrefix}); err != nil { + return + } + + if cc := s.First(fmt.Sprintf("%s.%s", settingsPrefix, key)); cc != nil { + return []byte(cc.String()), nil + } + + return +} + +func (l *CertStoreLoader) Load(ctx context.Context) (cc *Cert, err error) { + var ( + c, k []byte + ) + + if c, err = l.Cert(ctx); err != nil && c != nil { + return + } + + if k, err = l.Key(ctx); err != nil && k != nil { + return + } + + cc = &Cert{Cert: c, Key: k} + + return +} + +func (l *CertFsLoader) Cert(ctx context.Context) (c []byte, err error) { + return ioutil.ReadFile(l.CertFile) +} + +func (l *CertFsLoader) Key(ctx context.Context) ([]byte, error) { + return ioutil.ReadFile(l.KeyFile) +} + +func (l *CertFsLoader) Load(ctx context.Context) (cc *Cert, err error) { + var ( + c, k []byte + ) + + if c, err = l.Cert(ctx); err != nil && c != nil { + return + } + + if k, err = l.Key(ctx); err != nil && k != nil { + return + } + + cc = &Cert{Cert: c, Key: k} + + return +} + +func NewCertManager(l CertLoader) *certManager { + return &certManager{ + loader: l, + } +} diff --git a/auth/saml/cert_test.go b/auth/saml/cert_test.go new file mode 100644 index 000000000..f6e133e74 --- /dev/null +++ b/auth/saml/cert_test.go @@ -0,0 +1,90 @@ +package saml + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/cortezaproject/corteza-server/system/types" + "github.com/stretchr/testify/require" +) + +type ( + ssFn func(ctx context.Context, f types.SettingsFilter) (types.SettingValueSet, types.SettingsFilter, error) + certMockLoader struct { + } + + mockStorer struct { + ss func(ctx context.Context, f types.SettingsFilter) (types.SettingValueSet, types.SettingsFilter, error) + } +) + +var ( + c = &types.SettingValue{Name: fmt.Sprintf("%s.cert", settingsPrefix)} + k = &types.SettingValue{Name: fmt.Sprintf("%s.key", settingsPrefix)} +) + +func Test_loadCertFromSettings(t *testing.T) { + + tcc := []struct { + name string + err error + expect *Cert + ss ssFn + }{ + { + name: "cert, key load success", + expect: &Cert{Cert: []byte("CERT"), Key: []byte("KEY")}, + ss: func(ctx context.Context, f types.SettingsFilter) (types.SettingValueSet, types.SettingsFilter, error) { + c.SetValue("CERT") + k.SetValue("KEY") + + return types.SettingValueSet{c, k}, f, nil + }, + }, + { + name: "no permissions to fetch settings", + err: errors.New("no permissions"), + expect: &Cert{}, + ss: func(ctx context.Context, f types.SettingsFilter) (types.SettingValueSet, types.SettingsFilter, error) { + return nil, types.SettingsFilter{}, errors.New("no permissions") + }, + }, + { + name: "no cert, key in store", + expect: &Cert{}, + ss: func(ctx context.Context, f types.SettingsFilter) (types.SettingValueSet, types.SettingsFilter, error) { + return nil, types.SettingsFilter{}, nil + }, + }, + } + + for _, tc := range tcc { + t.Run(tc.name, func(t *testing.T) { + var ( + req = require.New(t) + loader = CertStoreLoader{ + Storer: mockStorer{ + ss: tc.ss, + }, + } + ) + + cc, err := loader.Load(context.Background()) + + if tc.err != nil { + req.Error(err) + } else { + req.NoError(err) + } + + req.Equal(tc.expect, cc) + }) + } + +} + +func (cm mockStorer) SearchSettings(ctx context.Context, f types.SettingsFilter) (types.SettingValueSet, types.SettingsFilter, error) { + return cm.ss(ctx, f) +} diff --git a/auth/saml/idp.go b/auth/saml/idp.go new file mode 100644 index 000000000..e45692b5f --- /dev/null +++ b/auth/saml/idp.go @@ -0,0 +1,9 @@ +package saml + +type ( + IdpIdentityPayload struct { + Name string + Handle string + Identifier string + } +) diff --git a/auth/saml/saml.go b/auth/saml/saml.go new file mode 100644 index 000000000..02abc104b --- /dev/null +++ b/auth/saml/saml.go @@ -0,0 +1,54 @@ +package saml + +import ( + "context" + "net/http" + "net/url" + + "github.com/cortezaproject/corteza-server/auth/settings" + "github.com/cortezaproject/corteza-server/system/types" + "github.com/crewjam/saml" + "github.com/crewjam/saml/samlsp" +) + +type ( + templateProvider struct { + Label, Handle, Icon string + } +) + +// FetchIDPMetadata loads the idp metadata, usually the url +// is configured in settings +func FetchIDPMetadata(ctx context.Context, u url.URL) (*saml.EntityDescriptor, error) { + return samlsp.FetchMetadata(ctx, http.DefaultClient, u) +} + +// TemplateProvider adds a wrapper to the button +// data that is displayed on the login form +func TemplateProvider(url, name string) templateProvider { + if name == "" { + name = url + } + + return templateProvider{ + Label: name, + Handle: "saml/init", + Icon: "key", + } +} + +// UpdateSettings applies the app settings to the +// auth specific settings +func UpdateSettings(source *types.AppSettings, dest *settings.Settings) { + cas := source.Auth + + dest.Saml.Enabled = cas.External.Saml.Enabled + dest.Saml.Cert = cas.External.Saml.Cert + dest.Saml.Key = cas.External.Saml.Key + + dest.Saml.IDP.URL = cas.External.Saml.IDP.URL + dest.Saml.IDP.Name = cas.External.Saml.IDP.Name + dest.Saml.IDP.IdentName = cas.External.Saml.IDP.IdentName + dest.Saml.IDP.IdentHandle = cas.External.Saml.IDP.IdentHandle + dest.Saml.IDP.IdentIdentifier = cas.External.Saml.IDP.IdentIdentifier +} diff --git a/auth/saml/sp.go b/auth/saml/sp.go new file mode 100644 index 000000000..86b794d09 --- /dev/null +++ b/auth/saml/sp.go @@ -0,0 +1,134 @@ +package saml + +import ( + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "net/http" + "net/url" + "strings" + + "github.com/crewjam/saml" + "github.com/crewjam/saml/samlsp" + "github.com/pkg/errors" +) + +const defaultNameIdentifier = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + +type ( + SamlSPService struct { + IdpURL url.URL + Host url.URL + IDPUserMeta *IdpIdentityPayload + IDPMeta *saml.EntityDescriptor + + sp saml.ServiceProvider + handler *samlsp.Middleware + } + + SamlSPArgs struct { + AcsURL string + MetaURL string + SloURL string + + // user meta from idp + IdentityPayload IdpIdentityPayload + + IdpURL url.URL + Host url.URL + Cert tls.Certificate + IdpMeta *saml.EntityDescriptor + } +) + +// NewSamlSPService loads the certificates and registers the +// already fetched IDP metadata into the SAML middleware +func NewSamlSPService(args SamlSPArgs) (s *SamlSPService, err error) { + var ( + keyPair = args.Cert + ) + + keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) + + if err != nil { + return + } + + metadataURL, _ := url.Parse(args.MetaURL) + acsURL, _ := url.Parse(args.AcsURL) + logoutURL, _ := url.Parse(args.SloURL) + + sp := saml.ServiceProvider{ + Key: keyPair.PrivateKey.(*rsa.PrivateKey), + Certificate: keyPair.Leaf, + IDPMetadata: args.IdpMeta, + + MetadataURL: *args.Host.ResolveReference(metadataURL), + AcsURL: *args.Host.ResolveReference(acsURL), + SloURL: *args.Host.ResolveReference(logoutURL), + } + + opts := samlsp.Options{ + URL: args.Host, + Key: sp.Key, + Certificate: sp.Certificate, + IDPMetadata: args.IdpMeta, + } + + // internal samlsp service + handler, err := samlsp.New(opts) + + if err != nil { + err = errors.Wrap(err, "could not init SAML SP handler") + return + } + + handler.RequestTracker = samlsp.DefaultRequestTracker(opts, &handler.ServiceProvider) + handler.ServiceProvider = sp + + s = &SamlSPService{ + sp: sp, + handler: handler, + + IdpURL: args.IdpURL, + Host: args.Host, + IDPUserMeta: &args.IdentityPayload, + } + + return +} + +func (ssp *SamlSPService) NameIdentifier() string { + return strings.TrimPrefix(defaultNameIdentifier, "urn:oasis:names:tc:SAML:1.1:nameid-format:") +} + +// GuessIdentifier tries to guess the necessary (email) key +// for external authentication +func (ssp *SamlSPService) GuessIdentifier(payload map[string][]string) string { + tryValues := []string{ + ssp.IDPUserMeta.Identifier, + ssp.NameIdentifier(), + defaultNameIdentifier, + "urn:oasis:names:tc:SAML:attribute:subject-id", + "email", + "mail", + } + + for _, v := range tryValues { + if _, ok := payload[v]; ok { + return payload[v][0] + } + } + + return "" +} + +func (ssp *SamlSPService) Handler() *samlsp.Middleware { + return ssp.handler +} + +// ServeHTTP enables us to use the service directly +// in the router +func (ssp SamlSPService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ssp.handler.ServeHTTP(w, r) +} diff --git a/auth/settings/settings.go b/auth/settings/settings.go index 635f037fc..41272f46c 100644 --- a/auth/settings/settings.go +++ b/auth/settings/settings.go @@ -9,6 +9,27 @@ type ( ExternalEnabled bool Providers []Provider + Saml struct { + Enabled bool + + // SAML certificate + Cert string + + // SAML certificate private key + Key string + + // Identity provider hostname + IDP struct { + URL string + Name string + + // identifier payload from idp + IdentName string + IdentHandle string + IdentIdentifier string + } + } + MultiFactor struct { EmailOTP struct { // Can users use email for MFA diff --git a/system/service/auth.go b/system/service/auth.go index 85369f480..8e6997dc1 100644 --- a/system/service/auth.go +++ b/system/service/auth.go @@ -60,8 +60,13 @@ var ( ) func defaultProviderValidator(provider string) error { - _, err := goth.GetProvider(provider) - return err + switch provider { + case "saml": + return nil + default: + _, err := goth.GetProvider(provider) + return err + } } func Auth() *auth { @@ -95,7 +100,7 @@ func Auth() *auth { // // External login/signup does not: // - validate provider on profile, only uses it for matching credentials -func (svc auth) External(ctx context.Context, profile goth.User) (u *types.User, err error) { +func (svc auth) External(ctx context.Context, profile types.ExternalAuthUser) (u *types.User, err error) { var ( authProvider = &types.AuthProvider{Provider: profile.Provider} @@ -123,6 +128,7 @@ func (svc auth) External(ctx context.Context, profile goth.User) (u *types.User, cc types.CredentialsSet f = types.CredentialsFilter{Kind: profile.Provider, Credentials: profile.UserID} ) + if cc, _, err = store.SearchCredentials(ctx, svc.store, f); err == nil { // Credentials found, load user for _, c := range cc { @@ -190,7 +196,6 @@ func (svc auth) External(ctx context.Context, profile goth.User) (u *types.User, // Find user via his email if u, err = store.LookupUserByEmail(ctx, svc.store, profile.Email); errors.IsNotFound(err) { // @todo check if it is ok to auto-create a user here - // In case we do not have this email, create a new user u = &types.User{ Email: profile.Email, diff --git a/system/types/app_settings.go b/system/types/app_settings.go index d02fe4ddb..4391d1cab 100644 --- a/system/types/app_settings.go +++ b/system/types/app_settings.go @@ -51,6 +51,28 @@ type ( // Is external authentication Enabled bool + // Saml + Saml struct { + Enabled bool + + // SAML certificate + Cert string `kv:"cert"` + + // SAML certificate private key + Key string `kv:"key"` + + // Identity provider settings + IDP struct { + URL string `kv:"url"` + Name string + + // identifier payload from idp + IdentName string `kv:"ident-name"` + IdentHandle string `kv:"ident-handle"` + IdentIdentifier string `kv:"ident-identifier"` + } `kv:"idp"` + } + // all external providers we know Providers ExternalAuthProviderSet } diff --git a/system/types/auth.go b/system/types/auth.go index 8212a4663..f9238bcbc 100644 --- a/system/types/auth.go +++ b/system/types/auth.go @@ -1,7 +1,13 @@ package types +import "github.com/markbates/goth" + type ( AuthProvider struct { Provider string } + + ExternalAuthUser struct { + goth.User + } ) diff --git a/vendor/github.com/crewjam/saml/samlsp/middleware.go b/vendor/github.com/crewjam/saml/samlsp/middleware.go index 181e41514..b158e9667 100644 --- a/vendor/github.com/crewjam/saml/samlsp/middleware.go +++ b/vendor/github.com/crewjam/saml/samlsp/middleware.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/crewjam/saml" - "github.com/davecgh/go-spew/spew" ) // Middleware implements middleware than allows a web application @@ -51,15 +50,12 @@ type Middleware struct { // on the URIs specified by m.ServiceProvider.MetadataURL and // m.ServiceProvider.AcsURL. func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { - spew.Dump("SERVE", r.URL.Path, m.ServiceProvider.MetadataURL) if r.URL.Path == m.ServiceProvider.MetadataURL.Path { - spew.Dump("1") m.serveMetadata(w, r) return } if r.URL.Path == m.ServiceProvider.AcsURL.Path { - spew.Dump("2") m.serveACS(w, r) return }