From ee40eb956cbeda7a62d3ab3ad33104929f1f0968 Mon Sep 17 00:00:00 2001 From: Shivashis Padhi Date: Tue, 22 Nov 2022 11:09:04 +0530 Subject: [PATCH] [MM-47384] Make OpenID Connect free for all (#21556) * wip: make OpenID Connect free-for-all * Deprecation note: GoogleOAuth, Office365OAuth * Improve deprecation comments Co-authored-by: Martin Kraft * Lint fix * Add model/oauthproviders, move google, openid, office365 from enterprise * Vet fixes * Remove redundant log Co-authored-by: Martin Kraft Co-authored-by: Mattermod --- api4/user_test.go | 2 +- app/user_test.go | 2 +- cmd/mattermost/main.go | 6 +- config/client.go | 24 +- model/license.go | 24 +- model/{ => oauthproviders}/gitlab/gitlab.go | 0 model/oauthproviders/google/google.go | 158 ++++++++ model/oauthproviders/google/google_test.go | 61 +++ model/oauthproviders/office365/office365.go | 116 ++++++ .../office365/office365_test.go | 43 +++ model/oauthproviders/openid/openid.go | 243 ++++++++++++ model/oauthproviders/openid/openid_test.go | 352 ++++++++++++++++++ 12 files changed, 1000 insertions(+), 31 deletions(-) rename model/{ => oauthproviders}/gitlab/gitlab.go (100%) create mode 100644 model/oauthproviders/google/google.go create mode 100644 model/oauthproviders/google/google_test.go create mode 100644 model/oauthproviders/office365/office365.go create mode 100644 model/oauthproviders/office365/office365_test.go create mode 100644 model/oauthproviders/openid/openid.go create mode 100644 model/oauthproviders/openid/openid_test.go diff --git a/api4/user_test.go b/api4/user_test.go index db15a06604..8185ce6c03 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -25,7 +25,7 @@ import ( "github.com/mattermost/mattermost-server/v6/shared/mail" "github.com/mattermost/mattermost-server/v6/utils/testutils" - _ "github.com/mattermost/mattermost-server/v6/model/gitlab" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/gitlab" ) func TestCreateUser(t *testing.T) { diff --git a/app/user_test.go b/app/user_test.go index 139cc1c1e5..64aa833295 100644 --- a/app/user_test.go +++ b/app/user_test.go @@ -23,7 +23,7 @@ import ( "github.com/mattermost/mattermost-server/v6/einterfaces" "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" "github.com/mattermost/mattermost-server/v6/model" - oauthgitlab "github.com/mattermost/mattermost-server/v6/model/gitlab" + oauthgitlab "github.com/mattermost/mattermost-server/v6/model/oauthproviders/gitlab" "github.com/mattermost/mattermost-server/v6/store" storemocks "github.com/mattermost/mattermost-server/v6/store/storetest/mocks" "github.com/mattermost/mattermost-server/v6/utils/testutils" diff --git a/cmd/mattermost/main.go b/cmd/mattermost/main.go index e39ed27059..b5f73a31f9 100644 --- a/cmd/mattermost/main.go +++ b/cmd/mattermost/main.go @@ -10,7 +10,11 @@ import ( // Import and register app layer slash commands _ "github.com/mattermost/mattermost-server/v6/app/slashcommands" // Plugins - _ "github.com/mattermost/mattermost-server/v6/model/gitlab" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/gitlab" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/google" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/office365" + _ "github.com/mattermost/mattermost-server/v6/model/oauthproviders/openid" + // Enterprise Imports _ "github.com/mattermost/mattermost-server/v6/imports" ) diff --git a/config/client.go b/config/client.go index 1eaef48c60..c6a8587865 100644 --- a/config/client.go +++ b/config/client.go @@ -298,11 +298,11 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["SamlLoginButtonColor"] = "" props["SamlLoginButtonBorderColor"] = "" props["SamlLoginButtonTextColor"] = "" - props["EnableSignUpWithGoogle"] = "false" - props["EnableSignUpWithOffice365"] = "false" - props["EnableSignUpWithOpenId"] = "false" - props["OpenIdButtonText"] = "" - props["OpenIdButtonColor"] = "" + props["EnableSignUpWithOpenId"] = strconv.FormatBool(*c.OpenIdSettings.Enable) + props["OpenIdButtonColor"] = *c.OpenIdSettings.ButtonColor + props["OpenIdButtonText"] = *c.OpenIdSettings.ButtonText + props["EnableSignUpWithGoogle"] = strconv.FormatBool(*c.GoogleSettings.Enable) + props["EnableSignUpWithOffice365"] = strconv.FormatBool(*c.Office365Settings.Enable) props["CWSURL"] = "" props["EnableCustomBrand"] = strconv.FormatBool(*c.TeamSettings.EnableCustomBrand) props["CustomBrandText"] = *c.TeamSettings.CustomBrandText @@ -329,20 +329,6 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["SamlLoginButtonTextColor"] = *c.SamlSettings.LoginButtonTextColor } - if *license.Features.GoogleOAuth { - props["EnableSignUpWithGoogle"] = strconv.FormatBool(*c.GoogleSettings.Enable) - } - - if *license.Features.Office365OAuth { - props["EnableSignUpWithOffice365"] = strconv.FormatBool(*c.Office365Settings.Enable) - } - - if *license.Features.OpenId { - props["EnableSignUpWithOpenId"] = strconv.FormatBool(*c.OpenIdSettings.Enable) - props["OpenIdButtonColor"] = *c.OpenIdSettings.ButtonColor - props["OpenIdButtonText"] = *c.OpenIdSettings.ButtonText - } - if *license.Features.CustomTermsOfService { props["EnableCustomTermsOfService"] = strconv.FormatBool(*c.SupportSettings.CustomTermsOfServiceEnabled) props["CustomTermsOfServiceReAcceptancePeriod"] = strconv.FormatInt(int64(*c.SupportSettings.CustomTermsOfServiceReAcceptancePeriod), 10) diff --git a/model/license.go b/model/license.go index ba0797c168..43a4c6af69 100644 --- a/model/license.go +++ b/model/license.go @@ -77,12 +77,18 @@ type TrialLicenseRequest struct { } type Features struct { - Users *int `json:"users"` - LDAP *bool `json:"ldap"` - LDAPGroups *bool `json:"ldap_groups"` - MFA *bool `json:"mfa"` - GoogleOAuth *bool `json:"google_oauth"` - Office365OAuth *bool `json:"office365_oauth"` + Users *int `json:"users"` + LDAP *bool `json:"ldap"` + LDAPGroups *bool `json:"ldap_groups"` + MFA *bool `json:"mfa"` + + // Deprecated: This feature will be removed from the license because it's available without a license. + GoogleOAuth *bool `json:"google_oauth"` + + // Deprecated: This feature will be removed from the license because it's available without a license. + Office365OAuth *bool `json:"office365_oauth"` + + // Deprecated: This feature will be removed from the license because it's available without a license. OpenId *bool `json:"openid"` Compliance *bool `json:"compliance"` Cluster *bool `json:"cluster"` @@ -164,15 +170,15 @@ func (f *Features) SetDefaults() { } if f.GoogleOAuth == nil { - f.GoogleOAuth = NewBool(*f.FutureFeatures) + f.GoogleOAuth = NewBool(true) } if f.Office365OAuth == nil { - f.Office365OAuth = NewBool(*f.FutureFeatures) + f.Office365OAuth = NewBool(true) } if f.OpenId == nil { - f.OpenId = NewBool(*f.FutureFeatures) + f.OpenId = NewBool(true) } if f.Compliance == nil { diff --git a/model/gitlab/gitlab.go b/model/oauthproviders/gitlab/gitlab.go similarity index 100% rename from model/gitlab/gitlab.go rename to model/oauthproviders/gitlab/gitlab.go diff --git a/model/oauthproviders/google/google.go b/model/oauthproviders/google/google.go new file mode 100644 index 0000000000..19c4cf3353 --- /dev/null +++ b/model/oauthproviders/google/google.go @@ -0,0 +1,158 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthgoogle + +import ( + "encoding/json" + "errors" + "io" + "strings" + + "github.com/mattermost/mattermost-server/v6/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" +) + +type GoogleProvider struct { +} + +type SourceElement struct { + Type string `json:"type"` + ID string `json:"id"` + Etag string `json:"etag"` + ProfileMetadata ProfileMetadata `json:"profileMetadata"` +} + +type ProfileMetadata struct { + ObjectType string `json:"objectType"` + UserTypes []string `json:"userTypes"` +} + +type GoogleUserRootMetadata struct { + Sources []SourceElement `json:"sources"` +} + +type GoogleUserMetadata struct { + Source map[string]string `json:"source"` +} + +type GoogleUserNameNode struct { + Metadata GoogleUserMetadata `json:"metadata"` + GivenName string `json:"givenName"` + FamilyName string `json:"familyName"` +} + +type GoogleGenericInfoNode struct { + Metadata GoogleUserMetadata `json:"metadata"` + Value string `json:"value"` +} + +type GoogleUser struct { + Metadata GoogleUserRootMetadata `json:"metadata"` + Nicknames []GoogleGenericInfoNode `json:"nicknames"` + Emails []GoogleGenericInfoNode `json:"emailAddresses"` + Names []GoogleUserNameNode `json:"names"` +} + +func init() { + provider := &GoogleProvider{} + einterfaces.RegisterOAuthProvider(model.ServiceGoogle, provider) +} + +func userFromGoogleUser(gu *GoogleUser) *model.User { + user := &model.User{} + + for _, e := range gu.Emails { + if e.Metadata.Source["type"] == "ACCOUNT" || e.Metadata.Source["type"] == "DOMAIN_PROFILE" { + user.Email = e.Value + user.Username = model.CleanUsername(strings.Split(user.Email, "@")[0]) + break + } + } + + for _, e := range gu.Names { + if e.Metadata.Source["type"] == "PROFILE" || e.Metadata.Source["type"] == "DOMAIN_PROFILE" { + user.FirstName = e.GivenName + user.LastName = e.FamilyName + break + } + } + + if len(gu.Nicknames) > 0 { + user.Nickname = gu.Nicknames[0].Value + } + + user.AuthData = new(string) + *user.AuthData = gu.getAuthData() + user.AuthService = model.ServiceGoogle + + return user +} + +func googleUserFromJSON(data io.Reader) (*GoogleUser, error) { + decoder := json.NewDecoder(data) + var gu GoogleUser + err := decoder.Decode(&gu) + if err != nil { + return nil, err + } + + return &gu, nil +} + +func (gu *GoogleUser) IsValid() error { + if len(gu.Metadata.Sources) == 0 { + return errors.New("invalid metadata sources") + } + + if len(gu.Emails) == 0 { + return errors.New("invalid emails") + } + + return nil +} + +func (gu *GoogleUser) getAuthData() string { + if len(gu.Metadata.Sources) > 0 { + return gu.Metadata.Sources[0].ID + } + + return "" +} + +func (m *GoogleProvider) GetIdentifier() string { + return model.ServiceGoogle +} + +func (m *GoogleProvider) GetUserFromJSON(data io.Reader, tokenUser *model.User) (*model.User, error) { + gu, err := googleUserFromJSON(data) + if err != nil { + return nil, err + } + return userFromGoogleUser(gu), nil +} + +func (m *GoogleProvider) GetAuthDataFromJSON(data io.Reader) (string, error) { + gu, err := googleUserFromJSON(data) + if err != nil { + return "", err + } + + if err = gu.IsValid(); err != nil { + return "", err + } + + return gu.getAuthData(), nil +} + +func (m *GoogleProvider) GetSSOSettings(config *model.Config, service string) (*model.SSOSettings, error) { + return &config.GoogleSettings, nil +} + +func (m *GoogleProvider) GetUserFromIdToken(idToken string) (*model.User, error) { + return nil, nil +} + +func (m *GoogleProvider) IsSameUser(dbUser, oauthUser *model.User) bool { + return dbUser.AuthData == oauthUser.AuthData +} diff --git a/model/oauthproviders/google/google_test.go b/model/oauthproviders/google/google_test.go new file mode 100644 index 0000000000..1f30efb8eb --- /dev/null +++ b/model/oauthproviders/google/google_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthgoogle + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGoogleUserFromJSON(t *testing.T) { + gu := GoogleUser{ + Metadata: GoogleUserRootMetadata{ + Sources: []SourceElement{ + { + Etag: "tag", + }, + }, + }, + Emails: []GoogleGenericInfoNode{ + { + Value: "ali@test.com", + }, + }, + Names: []GoogleUserNameNode{ + { + GivenName: "ali", + }, + }, + Nicknames: []GoogleGenericInfoNode{ + { + Value: "ila", + }, + }, + } + + provider := &GoogleProvider{} + + t.Run("valid google user", func(t *testing.T) { + b, err := json.Marshal(gu) + require.NoError(t, err) + + _, err = provider.GetUserFromJSON(bytes.NewReader(b), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(bytes.NewReader(b)) + require.NoError(t, err) + }) + + t.Run("empty body should fail without panic", func(t *testing.T) { + _, err := provider.GetUserFromJSON(strings.NewReader("{}"), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(strings.NewReader("{}")) + require.Error(t, err) + }) +} diff --git a/model/oauthproviders/office365/office365.go b/model/oauthproviders/office365/office365.go new file mode 100644 index 0000000000..7f23d9e51c --- /dev/null +++ b/model/oauthproviders/office365/office365.go @@ -0,0 +1,116 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthoffice365 + +import ( + "encoding/json" + "errors" + "io" + "strings" + + "github.com/mattermost/mattermost-server/v6/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" +) + +type Office365Provider struct { +} + +type Office365User struct { + Id string `json:"id"` + FirstName string `json:"givenName"` + LastName string `json:"surname"` + Mail string `json:"mail"` + UserPrincipalName string `json:"userPrincipalName"` +} + +func init() { + provider := &Office365Provider{} + einterfaces.RegisterOAuthProvider(model.ServiceOffice365, provider) +} + +func userFromOffice365User(of *Office365User) *model.User { + user := &model.User{} + user.FirstName = of.FirstName + user.LastName = of.LastName + + if of.Mail != "" { + user.Email = of.Mail + } else if strings.Contains(of.UserPrincipalName, "@") { + user.Email = of.UserPrincipalName + } + + if user.Email != "" { + user.Username = model.CleanUsername(strings.Split(user.Email, "@")[0]) + } + + user.AuthData = new(string) + *user.AuthData = of.Id + user.AuthService = model.ServiceOffice365 + + return user +} + +func office365UserFromJSON(data io.Reader) (*Office365User, error) { + decoder := json.NewDecoder(data) + var of Office365User + err := decoder.Decode(&of) + if err != nil { + return nil, err + } + + return &of, nil +} + +func (of *Office365User) IsValid() error { + if of.Id == "" { + return errors.New("invalid user id") + } + + if of.Mail == "" && !strings.Contains(of.UserPrincipalName, "@") { + return errors.New("invalid email") + } + + return nil +} + +func (of *Office365User) getAuthData() string { + return of.Id +} + +func (m *Office365Provider) GetIdentifier() string { + return model.ServiceOffice365 +} + +func (m *Office365Provider) GetUserFromJSON(data io.Reader, tokenUser *model.User) (*model.User, error) { + of, err := office365UserFromJSON(data) + if err != nil { + return nil, err + } + return userFromOffice365User(of), nil +} + +func (m *Office365Provider) GetAuthDataFromJSON(data io.Reader) (string, error) { + of, err := office365UserFromJSON(data) + if err != nil { + return "", err + } + + if err = of.IsValid(); err != nil { + return "", err + } + + return of.getAuthData(), nil +} + +func (m *Office365Provider) GetSSOSettings(config *model.Config, service string) (*model.SSOSettings, error) { + return config.Office365Settings.SSOSettings(), nil +} + +func (m *Office365Provider) GetUserFromIdToken(idToken string) (*model.User, error) { + return nil, nil +} + +func (m *Office365Provider) IsSameUser(dbUser, oauthUser *model.User) bool { + return dbUser.AuthData == oauthUser.AuthData +} diff --git a/model/oauthproviders/office365/office365_test.go b/model/oauthproviders/office365/office365_test.go new file mode 100644 index 0000000000..85496dd7ab --- /dev/null +++ b/model/oauthproviders/office365/office365_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthoffice365 + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOffice365UserFromJSON(t *testing.T) { + ou := Office365User{ + FirstName: "ali", + Id: "12345", + LastName: "maya", + Mail: "ali@test.com", + } + + provider := &Office365Provider{} + + t.Run("valid office365 user", func(t *testing.T) { + b, err := json.Marshal(ou) + require.NoError(t, err) + + _, err = provider.GetUserFromJSON(bytes.NewReader(b), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(bytes.NewReader(b)) + require.NoError(t, err) + }) + + t.Run("empty body should fail without panic", func(t *testing.T) { + _, err := provider.GetUserFromJSON(strings.NewReader("{}"), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(strings.NewReader("{}")) + require.Error(t, err) + }) +} diff --git a/model/oauthproviders/openid/openid.go b/model/oauthproviders/openid/openid.go new file mode 100644 index 0000000000..e48be5d913 --- /dev/null +++ b/model/oauthproviders/openid/openid.go @@ -0,0 +1,243 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthopenid + +import ( + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/mattermost/mattermost-server/v6/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" +) + +type CacheData struct { + Service string + Expires int64 + Settings model.SSOSettings +} + +type OpenIdMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserEndpoint string `json:"userinfo_endpoint"` + JwksURI string `json:"jwks_uri"` + Algorithms []string `json:"id_token_signing_alg_values_supported"` +} + +type OpenIdProvider struct { + CacheData *CacheData +} + +type OpenIdUser struct { + Id string `json:"sub"` + Oid string `json:"oid"` //Office 365 only + FirstName string `json:"given_name"` + LastName string `json:"family_name"` + Name string `json:"name"` + Nickname string `json:"nickname"` + Email string `json:"email"` +} + +func init() { + provider := &OpenIdProvider{} + einterfaces.RegisterOAuthProvider(model.ServiceOpenid, provider) +} + +func (o *OpenIdProvider) userFromOpenIdUser(u *OpenIdUser) *model.User { + user := &model.User{} + + user.Email = u.Email + user.Username = model.CleanUsername(strings.Split(user.Email, "@")[0]) + if o.CacheData.Service == model.ServiceGitlab && u.Nickname != "" { + user.Username = u.Nickname + } + + user.FirstName = u.FirstName + user.LastName = u.LastName + user.Nickname = u.Nickname + + user.AuthData = new(string) + *user.AuthData = o.getAuthData(u) + + return user +} + +func (o *OpenIdProvider) getAuthData(u *OpenIdUser) string { + if o.CacheData.Service == model.ServiceOffice365 { + if u.Oid != "" { + return u.Oid + } + } + return u.Id +} + +func openIDUserFromJSON(data io.Reader) (*OpenIdUser, error) { + decoder := json.NewDecoder(data) + var u OpenIdUser + err := decoder.Decode(&u) + if err != nil { + return nil, err + } + return &u, nil +} + +func (u *OpenIdUser) IsValid() error { + if u.Id == "" { + return errors.New("invalid id") + } + + if u.Email == "" { + return errors.New("invalid emails") + } + return nil +} + +func (u *OpenIdUser) GetIdentifier() string { + return model.ServiceOpenid +} + +func (o *OpenIdProvider) GetUserFromJSON(data io.Reader, tokenUser *model.User) (*model.User, error) { + oid, err := openIDUserFromJSON(data) + if err != nil { + return nil, err + } + jsonUser := o.userFromOpenIdUser(oid) + + if tokenUser != nil { + jsonUser = o.combineUsers(jsonUser, tokenUser) + } + return jsonUser, nil +} + +func (o *OpenIdProvider) combineUsers(jsonUser *model.User, tokenUser *model.User) *model.User { + if o.CacheData.Service == model.ServiceOffice365 { + jsonUser.AuthData = tokenUser.AuthData + } + return jsonUser +} + +func (o *OpenIdProvider) GetAuthDataFromJSON(data io.Reader) (string, error) { + u, err := openIDUserFromJSON(data) + if err != nil { + return "", err + } + + err = u.IsValid() + if err != nil { + return "", err + } + return o.getAuthData(u), nil +} + +// GetSSOSettings returns SSO Settings from Cache or Discovery Document +func (o *OpenIdProvider) GetSSOSettings(config *model.Config, service string) (*model.SSOSettings, error) { + settings := config.OpenIdSettings + if service == model.ServiceOffice365 { + settings = *config.Office365Settings.SSOSettings() + } else if service == model.ServiceGoogle { + settings = config.GoogleSettings + } else if service == model.ServiceGitlab { + settings = config.GitLabSettings + } + + if o.CacheData != nil && !settingsChanged(*o.CacheData, service, settings) && o.CacheData.Expires > time.Now().Unix() { + return &o.CacheData.Settings, nil + } + + var age int64 = 0 + if *settings.DiscoveryEndpoint != "" { + response, err := http.Get(*settings.DiscoveryEndpoint) + if err != nil { + return nil, err + } + defer response.Body.Close() + + for _, v := range strings.Split(response.Header.Get("Cache-Control"), ",") { + if strings.Contains(v, "max-age") { + ageValue := strings.Split(v, "=")[1] + age, _ = strconv.ParseInt(ageValue, 10, 64) + } + } + responseData, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + var openIDResponse OpenIdMetadata + err = json.Unmarshal(responseData, &openIDResponse) + if err != nil { + return nil, err + } + + settings.AuthEndpoint = &openIDResponse.AuthorizationEndpoint + settings.TokenEndpoint = &openIDResponse.TokenEndpoint + settings.UserAPIEndpoint = &openIDResponse.UserEndpoint + } + expires := time.Now().Unix() + age + + o.CacheData = &CacheData{ + Service: service, + Expires: expires, + Settings: settings, + } + return &settings, nil +} + +func settingsChanged(cacheData CacheData, service string, configSettings model.SSOSettings) bool { + if cacheData.Service == service && + cacheData.Settings.DiscoveryEndpoint == configSettings.DiscoveryEndpoint && + cacheData.Settings.Secret == configSettings.Secret && + cacheData.Settings.Id == configSettings.Id { + return false + } + return true +} + +func (o *OpenIdProvider) GetUserFromIdToken(idToken string) (*model.User, error) { + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return nil, errors.New("invalid Id Token") + } + + b, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } + + claims := &OpenIdUser{} + json.Unmarshal(b, &claims) + + return o.userFromOpenIdUser(claims), nil +} + +func (o *OpenIdProvider) IsSameUser(dbUser, oauthUser *model.User) bool { + // Office365 OAuth would store Ids without dashes. (ie. 0e8fddd450d344999a93a390ee8cb83d) + // Office365 OpenId will return as a formatted GUID (ie. '0e8fddd4-50d3-4499-9a93-a390ee8cb83d') + // If this is a UUID that starts with all zero. (ie. 00000000-0000-0000-be95-fe607df5dbeb) + // For backwards compatibility we store the auth data from OAuth as be95fe607df5dbeb + if dbUser.AuthData == nil || oauthUser.AuthData == nil { + return false + } + dbID := *dbUser.AuthData + oauthID := *oauthUser.AuthData + if dbID == "" || oauthID == "" { + return false + } + parts := strings.Split(oauthID, "-") + for _, part := range parts { + if strings.Count(part, "0") != len(part) { + if !strings.Contains(dbID, part) { + return false + } + } + } + return true +} diff --git a/model/oauthproviders/openid/openid_test.go b/model/oauthproviders/openid/openid_test.go new file mode 100644 index 0000000000..7e157c6a88 --- /dev/null +++ b/model/oauthproviders/openid/openid_test.go @@ -0,0 +1,352 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package oauthopenid + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v6/model" +) + +func TestGetAuthData(t *testing.T) { + ou := OpenIdUser{ + Id: "12345", + FirstName: "firstname", + LastName: "lastname", + Nickname: "nickname", + Email: "name@test.com", + Oid: "0e8fddd4-50d3-4499-9a93-a390ee8cb83d", + } + + provider := &OpenIdProvider{ + CacheData: &CacheData{ + Service: model.ServiceGitlab, + }, + } + + t.Run("validate return id", func(t *testing.T) { + authData := provider.getAuthData(&ou) + assert.Equal(t, ou.Id, authData) + }) + + provider.CacheData.Service = model.ServiceOffice365 + + fmt.Println(provider.CacheData.Service) + t.Run("validate Oid return", func(t *testing.T) { + authData := provider.getAuthData(&ou) + assert.Equal(t, ou.Oid, authData) + }) +} +func TestOpenIdUserFromJSON(t *testing.T) { + ou := OpenIdUser{ + Id: "12345", + FirstName: "firstname", + LastName: "lastname", + Nickname: "nickname", + Email: "name@test.com", + } + + provider := &OpenIdProvider{ + CacheData: &CacheData{ + Service: model.ServiceOpenid, + }, + } + + t.Run("valid OpenId user", func(t *testing.T) { + b, err := json.Marshal(ou) + require.NoError(t, err) + + _, err = provider.GetUserFromJSON(bytes.NewReader(b), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(bytes.NewReader(b)) + require.NoError(t, err) + }) + + t.Run("empty body should fail without panic", func(t *testing.T) { + _, err := provider.GetUserFromJSON(strings.NewReader("{}"), nil) + require.NoError(t, err) + + _, err = provider.GetAuthDataFromJSON(strings.NewReader("{}")) + require.Error(t, err) + }) + + t.Run("test getUserFromIdToken", func(t *testing.T) { + header := "dummyHeader" + payload := "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IlNjb3R0IiwiZmFtaWx5X25hbWUiOiJCaXNoZWwiLCJsb2NhbGUiOiJlbiIsImlhdCI6MTYwODI0OTg5MSwiZXhwIjoxNjA4MjUzNDkxfQ" + signature := "dummysignature" + + testToken := header + _, err := provider.GetUserFromIdToken(testToken) + require.Error(t, err) + + testToken = header + "." + payload + _, err = provider.GetUserFromIdToken(testToken) + require.Error(t, err) + + t.Run("non ascii string encoded in the payload", func(t *testing.T) { + cases := []struct { + payload string + expectedName string + }{ + { + payload: "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6InRlc3TFiMWhxb4iLCJmYW1pbHlfbmFtZSI6IkJpc2hlbCIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNjA4MjQ5ODkxLCJleHAiOjE2MDgyNTM0OTF9", + expectedName: "testňšž", + }, + { + payload: "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6IlNjb3R0IiwiZmFtaWx5X25hbWUiOiJCaXNoZWwiLCJsb2NhbGUiOiJlbiIsImlhdCI6MTYwODI0OTg5MSwiZXhwIjoxNjA4MjUzNDkxfQ", + expectedName: "Scott", + }, + { + payload: "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIxMDIyOTIwNzU1ODQ2LWtyM2JrMjBxdDRhMTlkODhqMWt1cjNqcnM2MmI2ZXFjLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXVkIjoiMTAyMjkyMDc1NTg0Ni1rcjNiazIwcXQ0YTE5ZDg4ajFrdXIzanJzNjJiNmVxYy5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjExMDIxNjMwMDI2MzA5MTY3MzQ2MSIsImhkIjoibWF0dGVybW9zdC5jb20iLCJlbWFpbCI6InNjb3R0LmJpc2hlbEBtYXR0ZXJtb3N0LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoiWTVscFFoQlR0UkxHUGZqZ1BLSUhzUSIsIm5hbWUiOiJTY290dCBCaXNoZWwiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EtL0FPaDE0R2dMR1Nfa19KV2dacmc1Y1BGLU9JNV9oUkhaREFvUUNoUFUyVE1VPXM5Ni1jIiwiZ2l2ZW5fbmFtZSI6InRlc3TEjcSNxI0iLCJmYW1pbHlfbmFtZSI6IkJpc2hlbCIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNjA4MjQ5ODkxLCJleHAiOjE2MDgyNTM0OTF9", + expectedName: "testččč", + }, + } + for _, c := range cases { + testToken = header + "." + c.payload + "." + signature + user, err := provider.GetUserFromIdToken(testToken) + require.NoError(t, err) + require.NotNil(t, user) + require.Equal(t, c.expectedName, user.FirstName) + } + }) + + }) +} + +func TestGetSSOSettings(t *testing.T) { + provider := &OpenIdProvider{ + CacheData: &CacheData{ + Service: model.ServiceOpenid, + }, + } + validJSON := `{ + "issuer": "issuer", + "authorization_endpoint": "authorization_endpoint", + "token_endpoint": "token_endpoint", + "userinfo_endpoint": "userinfo_endpoint", + "jwks_uri": "jwks_uri", + "id_token_signing_alg_values_supported": ["RS256"] + }` + var validFunctionCalled int + validServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Cache-Control", "max-age=3600") + fmt.Fprintln(w, validJSON) + validFunctionCalled++ + })) + defer validServer.Close() + + validConfig := model.Config{ + OpenIdSettings: model.SSOSettings{ + Enable: model.NewBool(true), + Secret: model.NewString("secret string"), + Id: model.NewString("id"), + Scope: model.NewString("profile openid email"), + AuthEndpoint: model.NewString(""), + TokenEndpoint: model.NewString(""), + UserAPIEndpoint: model.NewString(""), + DiscoveryEndpoint: model.NewString(validServer.URL), + }, + } + + t.Run("Error", func(t *testing.T) { + errorFunctionCalled := 0 + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + errorFunctionCalled++ + w.Header().Add("Cache-Control", "max-age=3600") + http.Error(w, "Not found", 404) + })) + + errCfg := validConfig + errCfg.OpenIdSettings.DiscoveryEndpoint = model.NewString(errorServer.URL) + _, err := provider.GetSSOSettings(&errCfg, model.ServiceOpenid) + assert.Error(t, err) + assert.Equal(t, 1, errorFunctionCalled) + }) + + t.Run("UseCache", func(t *testing.T) { + validFunctionCalled = 0 + + settings, _ := provider.GetSSOSettings(&validConfig, model.ServiceOpenid) + assert.Equal(t, "authorization_endpoint", *settings.AuthEndpoint) + assert.Equal(t, "token_endpoint", *settings.TokenEndpoint) + assert.Equal(t, "userinfo_endpoint", *settings.UserAPIEndpoint) + assert.Equal(t, 1, validFunctionCalled) + // Should set cache + assert.Equal(t, provider.CacheData.Settings, *settings) + assert.True(t, provider.CacheData.Expires > 0) + currentCacheExpires := provider.CacheData.Expires + + // Call again should come from cache + settings, _ = provider.GetSSOSettings(&validConfig, model.ServiceOpenid) + assert.Equal(t, provider.CacheData.Settings, *settings) + assert.Equal(t, currentCacheExpires, provider.CacheData.Expires) + // should still be 1 + assert.Equal(t, 1, validFunctionCalled) + }) + + t.Run("CacheExpired", func(t *testing.T) { + // reset to original cache settings + settings, _ := provider.GetSSOSettings(&validConfig, model.ServiceOpenid) + // Should set cache + assert.Equal(t, provider.CacheData.Settings, *settings) + + // set cache to expired + provider.CacheData.Expires = time.Now().Add(time.Duration(-1) * time.Minute).Unix() + + // same config, should call endpoint + validFunctionCalled = 0 + provider.GetSSOSettings(&validConfig, model.ServiceOpenid) + assert.Equal(t, 1, validFunctionCalled) + assert.True(t, provider.CacheData.Expires > time.Now().Unix()) + }) + + t.Run("NoCache", func(t *testing.T) { + noCacheFunctionCalled := 0 + noCacheServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, validJSON) + noCacheFunctionCalled++ + })) + defer noCacheServer.Close() + + newCfg := validConfig + newCfg.OpenIdSettings.DiscoveryEndpoint = model.NewString(noCacheServer.URL) + + settings, err := provider.GetSSOSettings(&newCfg, model.ServiceOpenid) + require.NoError(t, err) + assert.Equal(t, "authorization_endpoint", *settings.AuthEndpoint) + assert.Equal(t, "token_endpoint", *settings.TokenEndpoint) + assert.Equal(t, "userinfo_endpoint", *settings.UserAPIEndpoint) + assert.Equal(t, 1, noCacheFunctionCalled) + // Should set cache + assert.Equal(t, provider.CacheData.Settings, *settings) + // Cache Expires, set, less than, equal now. + assert.True(t, provider.CacheData.Expires <= time.Now().Unix()) + + // Call again, should call server again + _, err = provider.GetSSOSettings(&newCfg, model.ServiceOpenid) + require.NoError(t, err) + assert.Equal(t, 2, noCacheFunctionCalled) + }) + + t.Run("ChangeService", func(t *testing.T) { + // reset to original cache settings + settings, _ := provider.GetSSOSettings(&validConfig, model.ServiceOpenid) + // Should set cache + assert.Equal(t, provider.CacheData.Settings, *settings) + assert.True(t, provider.CacheData.Expires > time.Now().Unix()) + + // create identical setting for Google + googleCfg := model.Config{ + GoogleSettings: model.SSOSettings{}, + } + googleCfg.GoogleSettings = validConfig.OpenIdSettings + + // call with different service, same config settings + validFunctionCalled = 0 + provider.GetSSOSettings(&googleCfg, model.ServiceGoogle) + assert.Equal(t, model.ServiceGoogle, provider.CacheData.Service) + assert.Equal(t, 1, validFunctionCalled) + }) + + t.Run("ChangeConfigSettings", func(t *testing.T) { + secondFunctionCalled := 0 + secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Cache-Control", "max-age=3600") + fmt.Fprintln(w, validJSON) + secondFunctionCalled++ + })) + defer secondServer.Close() + + newCfg := validConfig + newCfg.OpenIdSettings.DiscoveryEndpoint = model.NewString(secondServer.URL) + + // new URL + settings, err := provider.GetSSOSettings(&newCfg, model.ServiceOpenid) + require.NoError(t, err) + assert.Equal(t, "authorization_endpoint", *settings.AuthEndpoint) + assert.Equal(t, "token_endpoint", *settings.TokenEndpoint) + assert.Equal(t, "userinfo_endpoint", *settings.UserAPIEndpoint) + assert.Equal(t, 1, secondFunctionCalled) + + // new secret + newCfg.OpenIdSettings.Secret = model.NewString("NewSecret") + _, err = provider.GetSSOSettings(&newCfg, model.ServiceOpenid) + require.NoError(t, err) + assert.Equal(t, newCfg.OpenIdSettings.Secret, provider.CacheData.Settings.Secret) + assert.Equal(t, 2, secondFunctionCalled) + + // new Id + newCfg.OpenIdSettings.Id = model.NewString("NewId") + _, err = provider.GetSSOSettings(&newCfg, model.ServiceOpenid) + require.NoError(t, err) + assert.Equal(t, newCfg.OpenIdSettings.Id, provider.CacheData.Settings.Id) + assert.Equal(t, 3, secondFunctionCalled) + }) +} + +func TestCacheControlPanic(t *testing.T) { + provider := &OpenIdProvider{ + CacheData: &CacheData{ + Service: model.ServiceOpenid, + }, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "no header") + })) + defer ts.Close() + + cfg := &model.Config{ + OpenIdSettings: model.SSOSettings{ + DiscoveryEndpoint: model.NewString(ts.URL), + }, + } + + require.NotPanics(t, func() { + provider.GetSSOSettings(cfg, model.ServiceOpenid) + }) +} + +func TestIsSameUser(t *testing.T) { + provider := &OpenIdProvider{ + CacheData: &CacheData{ + Service: model.ServiceOpenid, + }, + } + cases := []struct { + dbUser model.User + oauthUser model.User + verified bool + }{ + {model.User{AuthData: model.NewString("202993a800824dc1b4496d598d47c58a")}, model.User{AuthData: model.NewString("202993a8-0082-4dc1-b449-6d598d47c58a")}, true}, + {model.User{AuthData: model.NewString("202993a85a824dc1b4496d598d47c58a")}, model.User{AuthData: model.NewString("")}, false}, + {model.User{AuthData: model.NewString("")}, model.User{AuthData: model.NewString("202993a8-5a82-4dc1-b449-6d598d47c58a")}, false}, + {model.User{AuthData: model.NewString("be95fe607df5dbeb")}, model.User{AuthData: model.NewString("00000000-0000-0000-be95-fe607df5dbeb")}, true}, + {model.User{AuthData: model.NewString("be95fe607df5dbeb")}, model.User{AuthData: model.NewString("00000000-0000-0000-be90-fe607df5dbeb")}, false}, + {model.User{AuthData: model.NewString("be95fe607df5dbeb")}, model.User{AuthData: model.NewString("00000000-0000-0000-be95-fe607df5dbe0")}, false}, + {model.User{AuthData: model.NewString("hello")}, model.User{}, false}, + } + for _, c := range cases { + verified := provider.IsSameUser(&c.dbUser, &c.oauthUser) + if verified != c.verified { + if c.verified { + t.Logf("'%v' should have matched '%v'", c.dbUser, c.oauthUser) + } else { + t.Logf("'%v' should not have matched '%v'", c.dbUser, c.oauthUser) + } + t.FailNow() + } + } +}