diff --git a/system/commands/auth.go b/system/commands/auth.go index d3024703f..cd8bc1beb 100644 --- a/system/commands/auth.go +++ b/system/commands/auth.go @@ -22,7 +22,8 @@ import ( // Will perform OpenID connect auto-configuration func Auth(ctx context.Context, c *cli.Config) *cobra.Command { var ( - enableDiscoveredProvider bool + enableDiscoveredProvider bool + skipValidationOnAutoDiscoveredProvider bool ) cmd := &cobra.Command{ @@ -39,22 +40,22 @@ func Auth(ctx context.Context, c *cli.Config) *cobra.Command { var ( name, providerUrl = args[0], args[1] + es = service.DefaultAuthSettings ) - eas, err := external.ExternalAuthSettings(service.DefaultIntSettings) - cli.HandleError(err) + if !skipValidationOnAutoDiscoveredProvider { + // Do basic validation of external auth settings + // will fail if secret or url are not set + cli.HandleError(es.StaticValidateExternal()) - // Do basic validation of external auth settings - // will fail if secret or url are not set - cli.HandleError(eas.ValidateStatic()) - - // Do full rediredct-URL check - cli.HandleError(eas.ValidateRedirectURL()) + // Do full rediredct-URL check + cli.HandleError(es.ValidateExternalRedirectURL()) + } p, err := parseExternalProviderUrl(providerUrl) cli.HandleError(err) - eap, err := external.RegisterNewOpenIdClient(ctx, eas, name, p.String()) + eap, err := external.RegisterNewOpenIdClient(ctx, es, name, p.String()) cli.HandleError(err) vv, err := eap.MakeValueSet("openid-connect." + name) @@ -90,6 +91,12 @@ func Auth(ctx context.Context, c *cli.Config) *cobra.Command { false, "Enable this provider and external auth") + autoDiscoverCmd.Flags().BoolVar( + &skipValidationOnAutoDiscoveredProvider, + "skip-validation", + false, + "Skip validation") + jwtCmd := &cobra.Command{ Use: "jwt [email-or-id]", Short: "Generates new JWT for a user", diff --git a/system/internal/auth/external/external.go b/system/internal/auth/external/external.go index 115d3c571..2a359b410 100644 --- a/system/internal/auth/external/external.go +++ b/system/internal/auth/external/external.go @@ -3,16 +3,12 @@ package external import ( "go.uber.org/zap" - "github.com/cortezaproject/corteza-server/internal/settings" "github.com/cortezaproject/corteza-server/pkg/logger" + "github.com/cortezaproject/corteza-server/system/internal/service" ) -func Init(settingsService settings.Service) { - if eas, err := ExternalAuthSettings(settingsService); err != nil { - log().Error("failed load external authentication settings", zap.Error(err)) - } else { - setupGoth(eas) - } +func Init() { + setupGoth(service.DefaultAuthSettings) } func log() *zap.Logger { diff --git a/system/internal/auth/external/goth.go b/system/internal/auth/external/goth.go index 8b4960c3d..2b9ddd694 100644 --- a/system/internal/auth/external/goth.go +++ b/system/internal/auth/external/goth.go @@ -12,6 +12,8 @@ import ( "github.com/markbates/goth/providers/linkedin" "github.com/markbates/goth/providers/openidConnect" "go.uber.org/zap" + + "github.com/cortezaproject/corteza-server/system/internal/service" ) // We're expecting that our users will be able to complete @@ -22,16 +24,16 @@ const ( WellKnown = "/.well-known/openid-configuration" ) -func setupGoth(eas *externalAuthSettings) { - if eas == nil || !eas.enabled { +func setupGoth(as service.AuthSettings) { + if !as.ExternalEnabled { log().Info("external authentication disabled") return } - store := sessions.NewCookieStore([]byte(eas.sessionStoreSecret)) + store := sessions.NewCookieStore([]byte(as.ExternalSessionStoreSecret)) store.MaxAge(gothMaxSessionStoreAge) store.Options.HttpOnly = true - store.Options.Secure = eas.sessionStoreSecure + store.Options.Secure = as.ExternalSessionStoreSecure gothic.Store = store log().Debug("registering cookie session store") @@ -41,11 +43,11 @@ func setupGoth(eas *externalAuthSettings) { } - setupGothProviders(eas) + setupGothProviders(as) } -func setupGothProviders(eas *externalAuthSettings) { +func setupGothProviders(as service.AuthSettings) { var ( err error scopes = []string{"email"} @@ -58,32 +60,32 @@ func setupGothProviders(eas *externalAuthSettings) { } var enabled = 0 - for _, pc := range eas.providers { - if pc.enabled { + for _, pc := range as.ExternalProviders { + if pc.Enabled { enabled++ } } log().Debug("initializing enabled external authentication providers", zap.Int("count", enabled)) - for name, pc := range eas.providers { + for name, pc := range as.ExternalProviders { var provider goth.Provider log := log().With(zap.String("provider", name)) - if !pc.enabled { + if !pc.Enabled { continue } if strings.Index(name, "openid-connect.") == 0 { - if pc.issuerUrl == "" { + if pc.IssuerUrl == "" { log.Error("failed to discover OIDC provider, URL empty") continue } - wellKnown := strings.TrimSuffix(pc.issuerUrl, "/") + WellKnown + wellKnown := strings.TrimSuffix(pc.IssuerUrl, "/") + WellKnown - if provider, err = openidConnect.New(pc.key, pc.secret, pc.redirectUrl, wellKnown, scopes...); err != nil { + if provider, err = openidConnect.New(pc.Key, pc.Secret, pc.RedirectUrl, wellKnown, scopes...); err != nil { log.Error("failed to discover OIDC provider", zap.Error(err), zap.String("well-known", wellKnown)) continue } else { @@ -92,13 +94,13 @@ func setupGothProviders(eas *externalAuthSettings) { } else { switch name { case "github": - provider = github.New(pc.key, pc.secret, pc.redirectUrl, scopes...) + provider = github.New(pc.Key, pc.Secret, pc.RedirectUrl, scopes...) case "facebook": - provider = facebook.New(pc.key, pc.secret, pc.redirectUrl, scopes...) + provider = facebook.New(pc.Key, pc.Secret, pc.RedirectUrl, scopes...) case "gplus": - provider = gplus.New(pc.key, pc.secret, pc.redirectUrl, scopes...) + provider = gplus.New(pc.Key, pc.Secret, pc.RedirectUrl, scopes...) case "linkedin": - provider = linkedin.New(pc.key, pc.secret, pc.redirectUrl, scopes...) + provider = linkedin.New(pc.Key, pc.Secret, pc.RedirectUrl, scopes...) } } diff --git a/system/internal/auth/external/register.go b/system/internal/auth/external/register.go index 3e2267944..6fa2e1d3c 100644 --- a/system/internal/auth/external/register.go +++ b/system/internal/auth/external/register.go @@ -5,15 +5,17 @@ import ( "fmt" "github.com/crusttech/go-oidc" + + "github.com/cortezaproject/corteza-server/system/internal/service" ) // @todo remove dependency on github.com/crusttech/go-oidc (and github.com/coreos/go-oidc) // and move client registration to corteza codebase -func RegisterNewOpenIdClient(ctx context.Context, eas *externalAuthSettings, name, url string) (eap *externalAuthProvider, err error) { +func RegisterNewOpenIdClient(ctx context.Context, eas service.AuthSettings, name, url string) (eap *service.AuthSettingsExternalAuthProvider, err error) { var ( provider *oidc.Provider client *oidc.Client - redirectUrl = fmt.Sprintf(eas.redirectUrl, "openid-connect."+name) + redirectUrl = fmt.Sprintf(eas.ExternalRedirectUrl, "openid-connect."+name) ) if provider, err = oidc.NewProvider(ctx, url); err != nil { @@ -30,11 +32,11 @@ func RegisterNewOpenIdClient(ctx context.Context, eas *externalAuthSettings, nam return } - eap = &externalAuthProvider{ - redirectUrl: redirectUrl, - key: client.ID, - secret: client.Secret, - issuerUrl: url, + eap = &service.AuthSettingsExternalAuthProvider{ + RedirectUrl: redirectUrl, + Key: client.ID, + Secret: client.Secret, + IssuerUrl: url, } return diff --git a/system/internal/auth/external/settings.go b/system/internal/auth/external/settings.go deleted file mode 100644 index 4f0a074fd..000000000 --- a/system/internal/auth/external/settings.go +++ /dev/null @@ -1,225 +0,0 @@ -package external - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "strings" - - "github.com/pkg/errors" - - intset "github.com/cortezaproject/corteza-server/internal/settings" -) - -const ( - settingsKeyBase = "auth.external." - - settingsKeyProviders = settingsKeyBase + "providers." - settingsKeyRedirectUrl = settingsKeyBase + "redirect-url" - settingsKeySessionStoreSecret = settingsKeyBase + "session-store-secret" - settingsKeySessionStoreSecure = settingsKeyBase + "session-store-secure" -) - -type ( - externalAuthSettings struct { - enabled bool - redirectUrl string - sessionStoreSecret string - sessionStoreSecure bool - providers map[string]externalAuthProvider - } - - externalAuthProvider struct { - enabled bool - key string - secret string - redirectUrl string - issuerUrl string - } -) - -func ExternalAuthProvider(kv intset.KV) (eap externalAuthProvider, err error) { - for k, v := range kv { - ld := strings.LastIndex(k, ".") - - switch k[ld+1:] { - case "enabled": - err = v.Unmarshal(&eap.enabled) - case "key": - err = v.Unmarshal(&eap.key) - case "secret": - err = v.Unmarshal(&eap.secret) - case "issuer": - err = v.Unmarshal(&eap.issuerUrl) - } - - if err != nil { - return - } - } - - return -} - -func (eas externalAuthSettings) Enabled() bool { - return eas.enabled -} - -func (eas externalAuthSettings) ValidateStatic() error { - if eas.redirectUrl == "" { - return errors.New("redirect URL is empty") - } - - const ( - tpt = "test-provider-test" - ) - p, err := url.Parse(fmt.Sprintf(eas.redirectUrl, tpt)) - if err != nil { - return errors.Wrap(err, "invalid redirect URL") - } - - if !strings.Contains(p.Path, tpt+"/callback") { - return errors.Wrap(err, "could find injected provider in the URL, make sure you use '%s' as a placeholder") - } - - if eas.sessionStoreSecret == "" { - return errors.New("session store secret is empty") - } - - if eas.sessionStoreSecure && p.Scheme != "https" { - return errors.New("session store is secure, redirect URL should have HTTPS") - } - - return nil -} - -func (eas externalAuthSettings) ValidateRedirectURL() error { - const tpt = "test-provider-test" - const cb = "/callback" - - // Replace placeholders & remove /callback - var url = fmt.Sprintf(eas.redirectUrl, tpt) - url = url[0 : len(url)-len(cb)] - - rsp, err := http.DefaultClient.Get(url) - if err != nil { - return errors.Wrap(err, "could not get response from redirect URL") - } - - defer rsp.Body.Close() - body, err := ioutil.ReadAll(rsp.Body) - - if strings.Contains(string(body), tpt) { - return nil - } - - return errors.New("could not validate external auth redirection URL") -} - -func (eap externalAuthProvider) MakeValueSet(name string) (vv intset.ValueSet, err error) { - set := func(name string, value interface{}) error { - v := &intset.Value{Name: name} - if v.Value, err = json.Marshal(value); err != nil { - return err - } - - vv = append(vv, v) - return nil - } - - prefix := settingsKeyProviders + name - - if err = set(prefix+".enabled", eap.enabled); err != nil { - return nil, err - } - - if err = set(prefix+".key", eap.key); err != nil { - return nil, err - } - - if err = set(prefix+".secret", eap.secret); err != nil { - return nil, err - } - - if err = set(prefix+".issuer", eap.issuerUrl); err != nil { - return nil, err - } - - return vv, err -} - -// ExternalAuthSettings maps from plain values to externalAuthSettings struct -// -// see settings.Initialize() func -func ExternalAuthSettings(s intset.Service) (eas *externalAuthSettings, err error) { - // Read all settings and populate struct - settings, err := s.FindByPrefix(settingsKeyBase) - if err != nil { - return nil, errors.Wrap(err, "could not load settings for external auth provider") - } - - kv := settings.KV() - - eas = &externalAuthSettings{ - enabled: kv.Bool(settingsKeyBase + "enabled"), - redirectUrl: kv.String(settingsKeyRedirectUrl), - sessionStoreSecret: kv.String(settingsKeySessionStoreSecret), - } - - if !kv.Has(settingsKeySessionStoreSecure) { - // If auth.external.session-store-secure is not explicitly set; - // check if redirectUrl uses HTTPS schema and assume we want secure session store - eas.sessionStoreSecure = strings.Index(eas.redirectUrl, "https://") == 0 - } else { - eas.sessionStoreSecure = kv.Bool(settingsKeySessionStoreSecure) - } - - if eas.providers, err = extractProviders(eas.redirectUrl, kv); err != nil { - return nil, err - } - - return -} - -func extractProviders(redirectUrl string, kv intset.KV) (providers map[string]externalAuthProvider, err error) { - // Standard providers: - providers = map[string]externalAuthProvider{ - "github": {}, - "facebook": {}, - "gplus": {}, - "linkedin": {}, - } - - oidcKeyBase := settingsKeyProviders + "openid-connect." - for k := range kv.Filter(oidcKeyBase) { - if len(k) < len(oidcKeyBase)+2 { - // skip invalid keys - continue - } - - // find next dot: - name := k[len(oidcKeyBase):] - dotPos := strings.Index(name, ".") - if dotPos > 0 { - name = name[:dotPos] - } - - providers["openid-connect."+name] = externalAuthProvider{} - } - - for provider := range providers { - if eap, err := ExternalAuthProvider(kv.Filter(settingsKeyProviders + provider)); err != nil { - return nil, err - } else { - if eap.enabled { - eap.redirectUrl = fmt.Sprintf(redirectUrl, provider) - } - - providers[provider] = eap - } - } - - return -} diff --git a/system/internal/auth/external/settings_test.go b/system/internal/auth/external/settings_test.go deleted file mode 100644 index f06162ab9..000000000 --- a/system/internal/auth/external/settings_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package external - -import ( - "reflect" - "testing" - - intset "github.com/cortezaproject/corteza-server/internal/settings" - "github.com/jmoiron/sqlx/types" -) - -func Test_extractProviders(t *testing.T) { - type args struct { - redirectUrl string - kv intset.KV - } - tests := []struct { - name string - args args - wantProviders map[string]externalAuthProvider - wantErr bool - }{ - { - name: "Empty KV", - args: args{}, - wantProviders: map[string]externalAuthProvider{ - "github": externalAuthProvider{}, - "linkedin": externalAuthProvider{}, - "gplus": externalAuthProvider{}, - "facebook": externalAuthProvider{}, - }, - }, - - { - name: "Random config", - args: args{ - redirectUrl: "http://%s", - kv: intset.KV{ - "auth.external.providers.openid-connect.foo.enabled": types.JSONText("true"), - "auth.external.providers.openid-connect.foo.issuer": types.JSONText(`"url"`), - "auth.external.providers.openid-connect.foo.key": types.JSONText(`"key"`), - "auth.external.providers.openid-connect.foo.secret": types.JSONText(`"secret"`), - "auth.external.providers.openid-connect.bar.enabled": types.JSONText("true"), - "auth.external.providers.openid-connect.bar.issuer": types.JSONText(`"url"`), - "auth.external.providers.openid-connect.bar.key": types.JSONText(`"key"`), - "auth.external.providers.openid-connect.bar.secret": types.JSONText(`"secret"`), - "auth.external.providers.openid-connect.baz.enabled": types.JSONText("false"), - "auth.external.providers.github.enabled": types.JSONText(`false`), - "auth.external.providers.facebook.enabled": types.JSONText(`true`), - "auth.external.providers.facebook.secret": types.JSONText(`"fb-secret"`), - }, - }, - - wantProviders: map[string]externalAuthProvider{ - "openid-connect.foo": externalAuthProvider{ - enabled: true, - key: "key", - secret: "secret", - redirectUrl: "http://openid-connect.foo", - issuerUrl: "url", - }, - "openid-connect.bar": externalAuthProvider{ - enabled: true, - key: "key", - secret: "secret", - redirectUrl: "http://openid-connect.bar", - issuerUrl: "url", - }, - "openid-connect.baz": externalAuthProvider{ - enabled: false, - }, - "github": externalAuthProvider{ - enabled: false, - }, - "linkedin": externalAuthProvider{ - enabled: false, - }, - "gplus": externalAuthProvider{ - enabled: false, - }, - "facebook": externalAuthProvider{ - enabled: true, - secret: "fb-secret", - redirectUrl: "http://facebook", - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotProviders, err := extractProviders(tt.args.redirectUrl, tt.args.kv) - if (err != nil) != tt.wantErr { - t.Errorf("extractProviders() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(gotProviders, tt.wantProviders) { - t.Errorf("extractProviders() = %v, want %v", gotProviders, tt.wantProviders) - } - }) - } -} - -func TestExternalAuthProvider(t *testing.T) { - type args struct { - kv intset.KV - } - tests := []struct { - name string - args args - wantEap externalAuthProvider - wantErr bool - }{ - { - args: args{ - kv: intset.KV{ - "foo.enabled": types.JSONText("true"), - "foo.issuer": types.JSONText(`"example.tld"`), - "foo.key": types.JSONText(`"key"`), - "foo.secret": types.JSONText(`"secret"`), - }, - }, - - wantEap: externalAuthProvider{ - enabled: true, - key: "key", - secret: "secret", - issuerUrl: "example.tld", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotEap, err := ExternalAuthProvider(tt.args.kv) - if (err != nil) != tt.wantErr { - t.Errorf("ExternalAuthProvider() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(gotEap, tt.wantEap) { - t.Errorf("ExternalAuthProvider() = %v, want %v", gotEap, tt.wantEap) - } - }) - } -} diff --git a/system/internal/service/auth.go b/system/internal/service/auth.go index 2df3cfd9d..a8f71760c 100644 --- a/system/internal/service/auth.go +++ b/system/internal/service/auth.go @@ -29,7 +29,7 @@ type ( credentials repository.CredentialsRepository users repository.UserRepository roles repository.RoleRepository - settings authSettings + settings AuthSettings notifications AuthNotificationService providerValidator func(string) error @@ -130,7 +130,7 @@ func (svc auth) log(fields ...zapcore.Field) *zap.Logger { // 2.3. create credentials for that social login // func (svc auth) External(profile goth.User) (u *types.User, err error) { - if !svc.settings.externalEnabled { + if !svc.settings.ExternalEnabled { return nil, errors.New("external authentication disabled") } @@ -250,7 +250,7 @@ func (svc auth) External(profile goth.User) (u *types.User, err error) { // FrontendRedirectURL - a proxy to frontend redirect url setting func (svc auth) FrontendRedirectURL() string { - return svc.settings.frontendUrlRedirect + return svc.settings.FrontendUrlRedirect } // InternalSignUp protocol @@ -259,11 +259,11 @@ func (svc auth) FrontendRedirectURL() string { // // We're accepting the whole user object here and copy all we need to the new user func (svc auth) InternalSignUp(input *types.User, password string) (u *types.User, err error) { - if !svc.settings.internalEnabled { + if !svc.settings.InternalEnabled { return nil, errors.New("internal authentication disabled") } - if !svc.settings.internalSignUpEnabled { + if !svc.settings.InternalSignUpEnabled { return nil, errors.New("internal signup disabled") } @@ -324,7 +324,7 @@ func (svc auth) InternalSignUp(input *types.User, password string) (u *types.Use Handle: input.Handle, // Do we need confirmed email? - EmailConfirmed: !svc.settings.internalSignUpEmailConfirmationRequired, + EmailConfirmed: !svc.settings.InternalSignUpEmailConfirmationRequired, }) if err != nil { @@ -367,7 +367,7 @@ func (svc auth) validateInternalSignUp(email string) (err error) { // Expects plain text password as an input func (svc auth) InternalLogin(email string, password string) (u *types.User, err error) { - if !svc.settings.internalEnabled { + if !svc.settings.InternalEnabled { return nil, errors.New("internal authentication disabled") } @@ -468,7 +468,7 @@ func (svc auth) checkPassword(password string, cc types.CredentialsSet) (err err func (svc auth) SetPassword(userID uint64, newPassword string) (err error) { log := svc.log(zap.Uint64("userID", userID)) - if !svc.settings.internalEnabled { + if !svc.settings.InternalEnabled { return errors.New("internal authentication disabled") } @@ -490,7 +490,7 @@ func (svc auth) SetPassword(userID uint64, newPassword string) (err error) { func (svc auth) ChangePassword(userID uint64, oldPassword, newPassword string) (err error) { log := svc.log(zap.Uint64("userID", userID)) - if !svc.settings.internalEnabled { + if !svc.settings.InternalEnabled { return errors.New("internal authentication disabled") } @@ -577,7 +577,7 @@ func (svc auth) ValidateAuthRequestToken(token string) (user *types.User, err er } func (svc auth) ValidateEmailConfirmationToken(token string) (user *types.User, err error) { - if !svc.settings.internalEnabled { + if !svc.settings.InternalEnabled { return nil, errors.New("internal authentication disabled") } @@ -595,11 +595,11 @@ func (svc auth) ValidateEmailConfirmationToken(token string) (user *types.User, } func (svc auth) ValidatePasswordResetToken(token string) (user *types.User, err error) { - if !svc.settings.internalEnabled { + if !svc.settings.InternalEnabled { return nil, errors.New("internal authentication disabled") } - if !svc.settings.internalPasswordResetEnabled { + if !svc.settings.InternalPasswordResetEnabled { return nil, errors.New("password reset disabled") } @@ -619,12 +619,12 @@ func (svc auth) ValidatePasswordResetToken(token string) (user *types.User, err // ExchangePasswordResetToken exchanges reset password token for a new one and returns it with user info func (svc auth) ExchangePasswordResetToken(token string) (user *types.User, exchangedToken string, err error) { - if !svc.settings.internalEnabled { + if !svc.settings.InternalEnabled { err = errors.New("internal authentication disabled") return } - if !svc.settings.internalPasswordResetEnabled { + if !svc.settings.InternalPasswordResetEnabled { err = errors.New("password reset disabled") return } @@ -646,7 +646,7 @@ func (svc auth) ExchangePasswordResetToken(token string) (user *types.User, exch } func (svc auth) SendEmailAddressConfirmationToken(email string) error { - if !svc.settings.internalEnabled { + if !svc.settings.InternalEnabled { return errors.New("internal authentication disabled") } @@ -683,11 +683,11 @@ func (svc auth) sendEmailAddressConfirmationToken(u *types.User) (err error) { func (svc auth) SendPasswordResetToken(email string) error { - if !svc.settings.internalEnabled { + if !svc.settings.InternalEnabled { return errors.New("internal authentication disabled") } - if !svc.settings.internalPasswordResetEnabled { + if !svc.settings.InternalPasswordResetEnabled { return errors.New("password reset disabled") } diff --git a/system/internal/service/auth_notification.go b/system/internal/service/auth_notification.go index 17524864e..ec761d841 100644 --- a/system/internal/service/auth_notification.go +++ b/system/internal/service/auth_notification.go @@ -18,7 +18,7 @@ type ( ctx context.Context logger *zap.Logger - settings authSettings + settings AuthSettings } AuthNotificationService interface { @@ -127,20 +127,20 @@ func (svc authNotification) log(fields ...zapcore.Field) *zap.Logger { func (svc authNotification) EmailConfirmation(lang string, emailAddress string, token string) error { return svc.send("email-confirmation", lang, authNotificationPayload{ EmailAddress: emailAddress, - URL: svc.settings.frontendUrlEmailConfirmation + token, + URL: svc.settings.FrontendUrlEmailConfirmation + token, }) } func (svc authNotification) PasswordReset(lang string, emailAddress string, token string) error { return svc.send("password-reset", lang, authNotificationPayload{ EmailAddress: emailAddress, - URL: svc.settings.frontendUrlPasswordReset + token, + URL: svc.settings.FrontendUrlPasswordReset + token, }) } func (svc authNotification) newMail() *gomail.Message { m := gomail.NewMessage() - m.SetAddressHeader("From", svc.settings.mailFromAddress, svc.settings.mailFromName) + m.SetAddressHeader("From", svc.settings.MailFromAddress, svc.settings.MailFromName) return m } @@ -148,9 +148,9 @@ func (svc authNotification) send(name, lang string, payload authNotificationPayl ntf := svc.newMail() payload.Logo = template.URL(defaultLogo) - payload.BaseURL = svc.settings.frontendUrlBase - payload.SignatureName = svc.settings.mailFromName - payload.SignatureEmail = svc.settings.mailFromAddress + payload.BaseURL = svc.settings.FrontendUrlBase + payload.SignatureName = svc.settings.MailFromName + payload.SignatureEmail = svc.settings.MailFromAddress ntf.SetAddressHeader("To", payload.EmailAddress, "") ntf.SetHeader("Subject", svc.render(emailTemplates[name+"."+lang+".subject"], payload)) diff --git a/system/internal/service/auth_settings.go b/system/internal/service/auth_settings.go index 3a0553297..0b5ee350c 100644 --- a/system/internal/service/auth_settings.go +++ b/system/internal/service/auth_settings.go @@ -1,74 +1,182 @@ package service import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" "strings" "github.com/markbates/goth" + "github.com/pkg/errors" + + intset "github.com/cortezaproject/corteza-server/internal/settings" ) type ( - authSettings struct { + AuthSettings struct { // Password reset path ( "?token=" + ) - frontendUrlPasswordReset string + FrontendUrlPasswordReset string // EmailAddress confirmation path ( "?token=" + ) - frontendUrlEmailConfirmation string + FrontendUrlEmailConfirmation string // Where to redirect user after external auth flow - frontendUrlRedirect string + FrontendUrlRedirect string // Webapp Base URL - frontendUrlBase string + FrontendUrlBase string - mailFromAddress string - mailFromName string - - // Is external authentication - externalEnabled bool + MailFromAddress string + MailFromName string // Is internal authentication (username + password) enabled - internalEnabled bool + InternalEnabled bool // Can users register - internalSignUpEnabled bool + InternalSignUpEnabled bool // Users should confirm their emails when signing-up - internalSignUpEmailConfirmationRequired bool + InternalSignUpEmailConfirmationRequired bool // Can users reset their passwords - internalPasswordResetEnabled bool + InternalPasswordResetEnabled bool + + // Is external authentication + ExternalEnabled bool + + // Where to redirect (url used for registration) + ExternalRedirectUrl string + + // session secret to use + ExternalSessionStoreSecret string + + // session store should be secure + ExternalSessionStoreSecure bool + + // all external providers we know + ExternalProviders map[string]AuthSettingsExternalAuthProvider } - authSettingsStore interface { - Bool(string) bool - String(string) string + AuthSettingsExternalAuthProvider struct { + Enabled bool + Key string + Secret string + RedirectUrl string + IssuerUrl string } ) -// AuthSettings maps from plain values to authSettings struct +// ParseAuthSettings maps from plain values to AuthSettings struct // // see settings.Initialize() func -func AuthSettings(kv authSettingsStore) authSettings { - return authSettings{ - frontendUrlPasswordReset: kv.String("auth.frontend.url.password-reset"), - frontendUrlEmailConfirmation: kv.String("auth.frontend.url.email-confirmation"), - frontendUrlRedirect: kv.String("auth.frontend.url.redirect"), - frontendUrlBase: kv.String("auth.frontend.url.base"), +func ParseAuthSettings(kv intset.KV) (as AuthSettings, err error) { + as = AuthSettings{ + FrontendUrlPasswordReset: kv.String("auth.frontend.url.password-reset"), + FrontendUrlEmailConfirmation: kv.String("auth.frontend.url.email-confirmation"), + FrontendUrlRedirect: kv.String("auth.frontend.url.redirect"), + FrontendUrlBase: kv.String("auth.frontend.url.base"), - mailFromAddress: kv.String("auth.mail.from-address"), - mailFromName: kv.String("auth.mail.from-name"), + MailFromAddress: kv.String("auth.mail.from-address"), + MailFromName: kv.String("auth.mail.from-name"), - externalEnabled: kv.Bool("auth.external.enabled"), - internalEnabled: kv.Bool("auth.internal.enabled"), + InternalEnabled: kv.Bool("auth.internal.enabled"), - internalSignUpEnabled: kv.Bool("auth.internal.signup.enabled"), - internalSignUpEmailConfirmationRequired: kv.Bool("auth.internal.signup-email-confirmation-required"), + InternalSignUpEnabled: kv.Bool("auth.internal.signup.enabled"), + InternalSignUpEmailConfirmationRequired: kv.Bool("auth.internal.signup-email-confirmation-required"), - internalPasswordResetEnabled: kv.Bool("auth.internal.password-reset.enabled"), + InternalPasswordResetEnabled: kv.Bool("auth.internal.password-reset.enabled"), + + ExternalEnabled: kv.Bool("auth.external.enabled"), + + ExternalRedirectUrl: kv.String("auth.external.redirect-url"), + ExternalSessionStoreSecret: kv.String("auth.external.session-store-secret"), + ExternalSessionStoreSecure: kv.Bool("auth.external.session-store-secure"), } + + as.ExternalProviders, err = as.parseExternalProviders(kv) + + return } -func (s authSettings) Format() map[string]interface{} { +func (as *AuthSettings) parseExternalProviders(kv intset.KV) (map[string]AuthSettingsExternalAuthProvider, error) { + // Standard providers: + var ( + ep = map[string]AuthSettingsExternalAuthProvider{ + "github": {}, + "facebook": {}, + "gplus": {}, + "linkedin": {}, + } + + // Add all oidc providers we find + extKeyBase = "auth.external.providers." + oidcKeyBase = extKeyBase + "openid-connect." + ) + + for k := range kv.Filter(oidcKeyBase) { + if len(k) < len(oidcKeyBase)+2 { + // skip invalid keys + continue + } + + // find next dot: + name := k[len(oidcKeyBase):] + dotPos := strings.Index(name, ".") + if dotPos > 0 { + name = name[:dotPos] + } + + ep["openid-connect."+name] = AuthSettingsExternalAuthProvider{} + } + + for provider := range ep { + if p, err := as.parseExternalProvider(kv.Filter(extKeyBase + provider)); err != nil { + return nil, err + } else { + if as.ExternalRedirectUrl != "" && p.Enabled { + p.RedirectUrl = fmt.Sprintf(as.ExternalRedirectUrl, provider) + } + + ep[provider] = *p + } + + } + + return ep, nil +} + +// Parses external provider out of KV set +// +// Function only looks at the end of key string (after last dot) +// so passing multiple providers will result in overriding values +func (as *AuthSettings) parseExternalProvider(kv intset.KV) (p *AuthSettingsExternalAuthProvider, err error) { + p = &AuthSettingsExternalAuthProvider{} + + for k, v := range kv { + ld := strings.LastIndex(k, ".") + + switch k[ld+1:] { + case "enabled": + err = v.Unmarshal(&p.Enabled) + case "key": + err = v.Unmarshal(&p.Key) + case "secret": + err = v.Unmarshal(&p.Secret) + case "issuer": + err = v.Unmarshal(&p.IssuerUrl) + } + + if err != nil { + return + } + } + + return +} + +func (as AuthSettings) Format() map[string]interface{} { type ( externalProvider struct { Label string `json:"label"` @@ -109,12 +217,101 @@ func (s authSettings) Format() map[string]interface{} { } return map[string]interface{}{ - "internalEnabled": s.internalEnabled, - "internalPasswordResetEnabled": s.internalPasswordResetEnabled, - "internalSignUpEmailConfirmationRequired": s.internalSignUpEmailConfirmationRequired, - "internalSignUpEnabled": s.internalSignUpEnabled, + "internalEnabled": as.InternalEnabled, + "internalPasswordResetEnabled": as.InternalPasswordResetEnabled, + "internalSignUpEmailConfirmationRequired": as.InternalSignUpEmailConfirmationRequired, + "internalSignUpEnabled": as.InternalSignUpEnabled, - "externalEnabled": s.externalEnabled, + "externalEnabled": as.ExternalEnabled, "externalProviders": providers, } } + +// StaticValidateExternal +// +// Simple checks of external auth settings +func (as AuthSettings) StaticValidateExternal() error { + if as.ExternalRedirectUrl == "" { + return errors.New("redirect URL is empty") + } + + const ( + tpt = "test-provider-test" + ) + p, err := url.Parse(fmt.Sprintf(as.ExternalRedirectUrl, tpt)) + if err != nil { + return errors.Wrap(err, "invalid redirect URL") + } + + if !strings.Contains(p.Path, tpt+"/callback") { + return errors.Wrap(err, "could find injected provider in the URL, make sure you use '%s' as a placeholder") + } + + if as.ExternalSessionStoreSecret == "" { + return errors.New("session store secret is empty") + } + + if as.ExternalSessionStoreSecure && p.Scheme != "https" { + return errors.New("session store is secure, redirect URL should have HTTPS") + } + + return nil +} + +// ValidateExternalRedirectURL +// +// Validates external redirect URL +func (as AuthSettings) ValidateExternalRedirectURL() error { + const tpt = "test-provider-test" + const cb = "/callback" + + // Replace placeholders & remove /callback + var url = fmt.Sprintf(as.ExternalRedirectUrl, tpt) + url = url[0 : len(url)-len(cb)] + + rsp, err := http.DefaultClient.Get(url) + if err != nil { + return errors.Wrap(err, "could not get response from redirect URL") + } + + defer rsp.Body.Close() + body, err := ioutil.ReadAll(rsp.Body) + + if strings.Contains(string(body), tpt) { + return nil + } + + return errors.New("could not validate external auth redirection URL") +} + +func (p AuthSettingsExternalAuthProvider) MakeValueSet(name string) (vv intset.ValueSet, err error) { + set := func(name string, value interface{}) error { + v := &intset.Value{Name: name} + if v.Value, err = json.Marshal(value); err != nil { + return err + } + + vv = append(vv, v) + return nil + } + + prefix := "auth.external.providers." + name + + if err = set(prefix+".enabled", p.Enabled); err != nil { + return nil, err + } + + if err = set(prefix+".key", p.Key); err != nil { + return nil, err + } + + if err = set(prefix+".secret", p.Secret); err != nil { + return nil, err + } + + if err = set(prefix+".issuer", p.IssuerUrl); err != nil { + return nil, err + } + + return vv, err +} diff --git a/system/internal/service/auth_settings_test.go b/system/internal/service/auth_settings_test.go new file mode 100644 index 000000000..97321a5e8 --- /dev/null +++ b/system/internal/service/auth_settings_test.go @@ -0,0 +1,95 @@ +package service + +import ( + "reflect" + "testing" + + "github.com/jmoiron/sqlx/types" + + intset "github.com/cortezaproject/corteza-server/internal/settings" +) + +func Test_extractProviders(t *testing.T) { + type args struct { + redirectUrl string + kv intset.KV + } + tests := []struct { + name string + args args + wantProviders map[string]AuthSettingsExternalAuthProvider + wantErr bool + }{ + { + name: "Empty KV", + args: args{}, + wantProviders: map[string]AuthSettingsExternalAuthProvider{ + "github": AuthSettingsExternalAuthProvider{}, + "linkedin": AuthSettingsExternalAuthProvider{}, + "gplus": AuthSettingsExternalAuthProvider{}, + "facebook": AuthSettingsExternalAuthProvider{}, + }, + }, + + { + name: "Random config", + args: args{ + kv: intset.KV{ + "auth.external.redirect-url": types.JSONText(`"http://%s"`), + "auth.external.providers.openid-connect.foo.enabled": types.JSONText("true"), + "auth.external.providers.openid-connect.foo.issuer": types.JSONText(`"url"`), + "auth.external.providers.openid-connect.foo.key": types.JSONText(`"key"`), + "auth.external.providers.openid-connect.foo.secret": types.JSONText(`"secret"`), + "auth.external.providers.openid-connect.bar.enabled": types.JSONText("true"), + "auth.external.providers.openid-connect.bar.issuer": types.JSONText(`"url"`), + "auth.external.providers.openid-connect.bar.key": types.JSONText(`"key"`), + "auth.external.providers.openid-connect.bar.secret": types.JSONText(`"secret"`), + "auth.external.providers.openid-connect.baz.enabled": types.JSONText("false"), + "auth.external.providers.github.enabled": types.JSONText(`false`), + "auth.external.providers.facebook.enabled": types.JSONText(`true`), + "auth.external.providers.facebook.secret": types.JSONText(`"fb-secret"`), + }, + }, + + wantProviders: map[string]AuthSettingsExternalAuthProvider{ + "openid-connect.foo": AuthSettingsExternalAuthProvider{ + Enabled: true, + Key: "key", + Secret: "secret", + RedirectUrl: "http://openid-connect.foo", + IssuerUrl: "url", + }, + "openid-connect.bar": AuthSettingsExternalAuthProvider{ + Enabled: true, + Key: "key", + Secret: "secret", + RedirectUrl: "http://openid-connect.bar", + IssuerUrl: "url", + }, + "openid-connect.baz": AuthSettingsExternalAuthProvider{}, + "github": AuthSettingsExternalAuthProvider{}, + "linkedin": AuthSettingsExternalAuthProvider{}, + "gplus": AuthSettingsExternalAuthProvider{}, + "facebook": AuthSettingsExternalAuthProvider{ + Enabled: true, + Secret: "fb-secret", + RedirectUrl: "http://facebook", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + as, err := ParseAuthSettings(tt.args.kv) + if (err != nil) != tt.wantErr { + t.Errorf("extractProviders() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !reflect.DeepEqual(as.ExternalProviders, tt.wantProviders) { + t.Errorf("extractProviders()\ngot: %v\nwant: %v\n", as.ExternalProviders, tt.wantProviders) + } + }) + } +} diff --git a/system/internal/service/auth_test.go b/system/internal/service/auth_test.go index c7e60b3ed..4fd77273d 100644 --- a/system/internal/service/auth_test.go +++ b/system/internal/service/auth_test.go @@ -65,7 +65,7 @@ func TestAuth_External_Existing(t *testing.T) { svc := makeMockAuthService(usrRpoMock, crdRpoMock) svc.logger = zap.NewNop() - svc.settings.externalEnabled = true + svc.settings.ExternalEnabled = true { auser, err := svc.External(p) @@ -111,7 +111,7 @@ func TestAuth_External_NonExisting(t *testing.T) { svc := makeMockAuthService(usrRpoMock, crdRpoMock) svc.logger = zap.NewNop() - svc.settings.externalEnabled = true + svc.settings.ExternalEnabled = true { auser, err := svc.External(p) @@ -137,7 +137,7 @@ func Test_auth_validateInternalLogin(t *testing.T) { } svc := auth{} svc.logger = zap.NewNop() - svc.settings.internalEnabled = true + svc.settings.InternalEnabled = true for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/system/internal/service/service.go b/system/internal/service/service.go index 41eb6d489..78d078b93 100644 --- a/system/internal/service/service.go +++ b/system/internal/service/service.go @@ -30,7 +30,7 @@ var ( DefaultSettings SettingsService DefaultAuthNotification AuthNotificationService - DefaultAuthSettings authSettings + DefaultAuthSettings AuthSettings DefaultAuth AuthService DefaultUser UserService diff --git a/system/internal/service/settings.go b/system/internal/service/settings.go index d869ad9f7..540d1a475 100644 --- a/system/internal/service/settings.go +++ b/system/internal/service/settings.go @@ -35,7 +35,7 @@ type ( BulkSet(vv internalSettings.ValueSet) (err error) Get(name string, ownedBy uint64) (out *internalSettings.Value, err error) - LoadAuthSettings() (authSettings, error) + LoadAuthSettings() (AuthSettings, error) AutoDiscovery() error } ) @@ -99,12 +99,12 @@ func (svc settings) Get(name string, ownedBy uint64) (out *internalSettings.Valu } // Loads auth.% settings, initializes & fills auth settings struct -func (svc settings) LoadAuthSettings() (authSettings, error) { +func (svc settings) LoadAuthSettings() (AuthSettings, error) { vv, err := svc.internalSettings.FindByPrefix("auth.") if err != nil { - return authSettings{}, err + return AuthSettings{}, err } - return AuthSettings(vv.KV()), nil + return ParseAuthSettings(vv.KV()) } // AutoDiscovery orchestrates settings auto discovery diff --git a/system/internal/service/user.go b/system/internal/service/user.go index b3be09019..d1b47a124 100644 --- a/system/internal/service/user.go +++ b/system/internal/service/user.go @@ -26,7 +26,7 @@ type ( ctx context.Context logger *zap.Logger - settings authSettings + settings AuthSettings auth userAuth @@ -245,7 +245,7 @@ func (svc user) Unsuspend(ID uint64) (err error) { func (svc user) SetPassword(userID uint64, newPassword string) (err error) { log := svc.log(zap.Uint64("userID", userID)) - if !svc.settings.internalEnabled { + if !svc.settings.InternalEnabled { return errors.New("internal authentication disabled") } diff --git a/system/system.go b/system/system.go index 77537265a..2e2cc06ef 100644 --- a/system/system.go +++ b/system/system.go @@ -64,7 +64,8 @@ func Configure() *cli.Config { service.DefaultAuthSettings, _ = service.DefaultSettings.LoadAuthSettings() } - external.Init(service.DefaultIntSettings) + // Initialize external authentication (from default settings) + external.Init() go service.Watchers(ctx) return nil },