loginsrv

Unnamed repository; edit this file 'description' to name the repository.
git clone git@jamesshield.xyz:repos/loginsrv.git
Log | Files | Refs | README | LICENSE

commit 2bb2308beaef46e3bef1c6d552486f4fa3c20ebb
parent 7a8566d795ccd0337dbd35078817f06ae5edccb1
Author: Sebastian Mancke <sebastian.mancke@snabble.io>
Date:   Thu,  3 Jan 2019 21:18:42 +0100

Merge pull request #92 from JesusIslam/master

[Feature] Adding Gitlab Provider
Diffstat:
MREADME.md | 14+++++++++++++-
Mlogin/user_claims.go | 20++++++++++++++++++--
Mlogin/user_claims_test.go | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mmodel/user_info.go | 20++++++++++++--------
Mmodel/user_info_test.go | 4+++-
Aoauth2/gitlab.go | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aoauth2/gitlab_test.go | 257+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Moauth2/provider_test.go | 10++++++++--
8 files changed, 487 insertions(+), 18 deletions(-)

diff --git a/README.md b/README.md @@ -32,7 +32,8 @@ The following providers (login backends) are supported. * Google login * Bitbucket login * Facebook login - + * Gitlab login + ## Questions For questions and support please use the [Gitter chat room](https://gitter.im/tarent/loginsrv). @@ -54,6 +55,7 @@ _Note for Caddy users_: Not all parameters are available in Caddy. See the table | -google | value | | X | OAuth config in the form: client_id=..,client_secret=..,scope=..[redirect_uri=..] | | -bitbucket | value | | X | OAuth config in the form: client_id=..,client_secret=..,[,scope=..][redirect_uri=..] | | -facebook | value | | X | OAuth config in the form: client_id=..,client_secret=..,scope=email..[redirect_uri=..] | +| -gitlab | value | | X | OAuth config in the form: client_id=..,client_secret=..[,scope=..,][redirect_uri=..] | -host | string | "localhost" | - | Host to listen on | | -htpasswd | value | | X | Htpasswd login backend opts: file=/path/to/pwdfile | | -jwt-expiry | go duration | 24h | X | Expiry duration for the JWT token, e.g. 2h or 3h30m | @@ -296,6 +298,7 @@ Currently the following OAuth provider is supported: * Google (see note below) * Bitbucket * Facebook (see note below) +* Gitlab An OAuth provider supports the following parameters: @@ -374,11 +377,13 @@ below the claim attribute are written into the token. The following attributes c * `origin` - the provider or backend name (all backends) * `email` - the mail address (the OAuth provider) * `domain` - the domain (Google only) +* `groups` - the full path string of user groups enclosed in an array (Gitlab only) Example: * The user bob will become the `"role": "superAdmin"`, when authenticating with htpasswd file * The user admin@example.org will become `"role": "admin"` and `"projects": ["example"]`, when authenticating with Google OAuth * All other Google users with the domain example will become `"role": "user"` and `"projects": ["example"]` +* All other Gitlab users with group `example/subgroup` and `othergroup` will become `"role": "admin"`. * All others will become `"role": "unknown"`, indenpendent of the authentication provider ``` @@ -401,6 +406,13 @@ Example: projects: - example +- groups: + - example/subgroup + - othergroup + origin: gitlab + claims: + role: admin + - claims: role: unknown ``` diff --git a/login/user_claims.go b/login/user_claims.go @@ -1,12 +1,13 @@ package login import ( + "io/ioutil" + "time" + "github.com/dgrijalva/jwt-go" "github.com/pkg/errors" "github.com/tarent/loginsrv/model" "gopkg.in/yaml.v2" - "io/ioutil" - "time" ) type customClaims map[string]interface{} @@ -27,6 +28,7 @@ type userFileEntry struct { Origin string `yaml:"origin"` Email string `yaml:"email"` Domain string `yaml:"domain"` + Groups []string `yaml:"groups"` Claims map[string]interface{} `yaml:"claims"` } @@ -87,5 +89,19 @@ func match(userInfo model.UserInfo, entry userFileEntry) bool { if entry.Origin != "" && entry.Origin != userInfo.Origin { return false } + if len(entry.Groups) > 0 { + eligible := false + for _, entryGroup := range entry.Groups { + for _, userGroup := range userInfo.Groups { + if entryGroup == userGroup { + eligible = true + break + } + } + } + if !eligible { + return false + } + } return true } diff --git a/login/user_claims_test.go b/login/user_claims_test.go @@ -1,11 +1,13 @@ package login import ( - . "github.com/stretchr/testify/assert" - "github.com/tarent/loginsrv/model" "io/ioutil" "os" "testing" + "time" + + . "github.com/stretchr/testify/assert" + "github.com/tarent/loginsrv/model" ) var claimsExample = ` @@ -29,10 +31,45 @@ var claimsExample = ` projects: - example +- origin: gitlab + groups: + - "example/subgroup" + - othergroup + claims: + role: admin + - claims: role: unknown ` +var invalidClaimsExample = ` +- sub: bob + origin: google +` + +func Test_ParseUserClaims_InvalidFile(t *testing.T) { + c, err := NewUserClaims(&Config{UserFile: "notfound"}) + Error(t, err) + Equal(t, &UserClaims{ + userFile: "notfound", + userFileEntries: []userFileEntry{}, + }, c) +} + +func Test_ParseUserClaims_InvalidYAML(t *testing.T) { + f, _ := ioutil.TempFile("", "") + f.WriteString(invalidClaimsExample) + f.Close() + defer os.Remove(f.Name()) + + c, err := NewUserClaims(&Config{UserFile: f.Name()}) + Error(t, err) + Equal(t, &UserClaims{ + userFile: f.Name(), + userFileEntries: []userFileEntry{}, + }, c) +} + func Test_UserClaims_ParseUserClaims(t *testing.T) { f, _ := ioutil.TempFile("", "") f.WriteString(claimsExample) @@ -41,11 +78,12 @@ func Test_UserClaims_ParseUserClaims(t *testing.T) { c, err := NewUserClaims(&Config{UserFile: f.Name()}) NoError(t, err) - Equal(t, 4, len(c.userFileEntries)) + Equal(t, 5, len(c.userFileEntries)) Equal(t, "admin@example.org", c.userFileEntries[1].Email) Equal(t, "google", c.userFileEntries[1].Origin) Equal(t, "admin", c.userFileEntries[1].Claims["role"]) Equal(t, []interface{}{"example"}, c.userFileEntries[1].Claims["projects"]) + Equal(t, []string{"example/subgroup", "othergroup"}, c.userFileEntries[3].Groups) } func Test_UserClaims_Claims(t *testing.T) { @@ -65,6 +103,10 @@ func Test_UserClaims_Claims(t *testing.T) { claims, _ = c.Claims(model.UserInfo{Sub: "any", Email: "admin@example.org", Origin: "google"}) Equal(t, customClaims{"sub": "overwrittenSubject", "email": "admin@example.org", "origin": "google", "role": "admin", "projects": []interface{}{"example"}}, claims) + // Match fourth entry + claims, _ = c.Claims(model.UserInfo{Sub: "any", Groups: []string{"example/subgroup", "othergroup"}, Origin: "gitlab"}) + Equal(t, customClaims{"sub": "any", "groups": []string{"example/subgroup", "othergroup"}, "origin": "gitlab", "role": "admin"}, claims) + // default case with no rules claims, _ = c.Claims(model.UserInfo{Sub: "bob"}) Equal(t, customClaims{"sub": "bob", "role": "unknown"}, claims) @@ -74,6 +116,8 @@ func Test_UserClaims_NoMatch(t *testing.T) { f, _ := ioutil.TempFile("", "") f.WriteString(` - sub: bob + groups: + - othergroup claims: role: superAdmin `) @@ -83,8 +127,30 @@ func Test_UserClaims_NoMatch(t *testing.T) { c, err := NewUserClaims(&Config{UserFile: f.Name()}) NoError(t, err) - // Mo Match -> not Modified + // No Match -> not Modified claims, err := c.Claims(model.UserInfo{Sub: "foo"}) NoError(t, err) Equal(t, model.UserInfo{Sub: "foo"}, claims) + + claims, err = c.Claims(model.UserInfo{Sub: "bob", Groups: []string{"group"}}) + NoError(t, err) + Equal(t, model.UserInfo{Sub: "bob", Groups: []string{"group"}}, claims) +} + +func Test_UserClaims_Valid(t *testing.T) { + cc := customClaims{ + "exp": time.Now().Unix() + 3600, + } + + err := cc.Valid() + NoError(t, err) +} + +func Test_UserClaims_Invalid(t *testing.T) { + cc := customClaims{ + "exp": time.Now().Unix() - 3600, + } + + err := cc.Valid() + Error(t, err) } diff --git a/model/user_info.go b/model/user_info.go @@ -8,14 +8,15 @@ import ( // UserInfo holds the parameters returned by the backends. // This information will be serialized to build the JWT token contents. type UserInfo struct { - Sub string `json:"sub"` - Picture string `json:"picture,omitempty"` - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` - Origin string `json:"origin,omitempty"` - Expiry int64 `json:"exp,omitempty"` - Refreshes int `json:"refs,omitempty"` - Domain string `json:"domain,omitempty"` + Sub string `json:"sub"` + Picture string `json:"picture,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Origin string `json:"origin,omitempty"` + Expiry int64 `json:"exp,omitempty"` + Refreshes int `json:"refs,omitempty"` + Domain string `json:"domain,omitempty"` + Groups []string `json:"groups,omitempty"` } // Valid lets us use the user info as Claim for jwt-go. @@ -52,5 +53,8 @@ func (u UserInfo) AsMap() map[string]interface{} { if u.Domain != "" { m["domain"] = u.Domain } + if len(u.Groups) > 0 { + m["groups"] = u.Groups + } return m } diff --git a/model/user_info_test.go b/model/user_info_test.go @@ -2,9 +2,10 @@ package model import ( "encoding/json" - . "github.com/stretchr/testify/assert" "testing" "time" + + . "github.com/stretchr/testify/assert" ) func Test_UserInfo_Valid(t *testing.T) { @@ -23,6 +24,7 @@ func Test_UserInfo_AsMap(t *testing.T) { Expiry: 23, Refreshes: 42, Domain: `json:"domain,omitempty"`, + Groups: []string{`json:"groups,omitempty"`}, } givenJson, _ := json.Marshal(u.AsMap()) diff --git a/oauth2/gitlab.go b/oauth2/gitlab.go @@ -0,0 +1,106 @@ +package oauth2 + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + + "github.com/tarent/loginsrv/model" +) + +var gitlabAPI = "https://gitlab.com/api/v4" + +func init() { + RegisterProvider(providerGitlab) +} + +// GitlabUser is used for parsing the gitlab response +type GitlabUser struct { + Username string `json:"username,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` +} + +type GitlabGroup struct { + FullPath string `json:"full_path,omitempty"` +} + +var providerGitlab = Provider{ + Name: "gitlab", + AuthURL: "https://gitlab.com/oauth/authorize", + TokenURL: "https://gitlab.com/oauth/token", + GetUserInfo: func(token TokenInfo) (model.UserInfo, string, error) { + gu := GitlabUser{} + url := fmt.Sprintf("%v/user?access_token=%v", gitlabAPI, token.AccessToken) + + var respUser *http.Response + respUser, err := http.Get(url) + if err != nil { + return model.UserInfo{}, "", err + } + defer respUser.Body.Close() + + if !strings.Contains(respUser.Header.Get("Content-Type"), "application/json") { + return model.UserInfo{}, "", fmt.Errorf("wrong content-type on gitlab get user info: %v", respUser.Header.Get("Content-Type")) + } + + if respUser.StatusCode != 200 { + return model.UserInfo{}, "", fmt.Errorf("got http status %v on gitlab get user info", respUser.StatusCode) + } + + b, err := ioutil.ReadAll(respUser.Body) + if err != nil { + return model.UserInfo{}, "", fmt.Errorf("error reading gitlab get user info: %v", err) + } + + err = json.Unmarshal(b, &gu) + if err != nil { + return model.UserInfo{}, "", fmt.Errorf("error parsing gitlab get user info: %v", err) + } + + gg := []*GitlabGroup{} + url = fmt.Sprintf("%v/groups?access_token=%v", gitlabAPI, token.AccessToken) + + var respGroup *http.Response + respGroup, err = http.Get(url) + if err != nil { + return model.UserInfo{}, "", err + } + defer respGroup.Body.Close() + + if !strings.Contains(respGroup.Header.Get("Content-Type"), "application/json") { + return model.UserInfo{}, "", fmt.Errorf("wrong content-type on gitlab get groups info: %v", respGroup.Header.Get("Content-Type")) + } + + if respGroup.StatusCode != 200 { + return model.UserInfo{}, "", fmt.Errorf("got http status %v on gitlab get groups info", respGroup.StatusCode) + } + + g, err := ioutil.ReadAll(respGroup.Body) + if err != nil { + return model.UserInfo{}, "", fmt.Errorf("error reading gitlab get groups info: %v", err) + } + + err = json.Unmarshal(g, &gg) + if err != nil { + return model.UserInfo{}, "", fmt.Errorf("error parsing gitlab get groups info: %v", err) + } + + groups := make([]string, len(gg)) + for i := 0; i < len(gg); i++ { + groups[i] = gg[i].FullPath + } + + return model.UserInfo{ + Sub: gu.Username, + Picture: gu.AvatarURL, + Name: gu.Name, + Email: gu.Email, + Groups: groups, + Origin: "gitlab", + }, `{"user":` + string(b) + `,"groups":` + string(g) + `}`, nil + }, +} diff --git a/oauth2/gitlab_test.go b/oauth2/gitlab_test.go @@ -0,0 +1,257 @@ +package oauth2 + +import ( + "net/http" + "net/http/httptest" + "regexp" + "testing" + + . "github.com/stretchr/testify/assert" + "github.com/tarent/loginsrv/model" +) + +var gitlabTestUserResponse = `{ + "id": 1, + "username": "john_smith", + "email": "john@example.com", + "name": "John Smith", + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/index.jpg", + "web_url": "http://localhost:3000/john_smith", + "created_at": "2012-05-23T08:00:58Z", + "bio": null, + "location": null, + "public_email": "john@example.com", + "skype": "", + "linkedin": "", + "twitter": "", + "website_url": "", + "organization": "", + "last_sign_in_at": "2012-06-01T11:41:01Z", + "confirmed_at": "2012-05-23T09:05:22Z", + "theme_id": 1, + "last_activity_on": "2012-05-23", + "color_scheme_id": 2, + "projects_limit": 100, + "current_sign_in_at": "2012-06-02T06:36:55Z", + "identities": [ + {"provider": "github", "extern_uid": "2435223452345"}, + {"provider": "bitbucket", "extern_uid": "john_smith"}, + {"provider": "google_oauth2", "extern_uid": "8776128412476123468721346"} + ], + "can_create_group": true, + "can_create_project": true, + "two_factor_enabled": true, + "external": false, + "private_profile": false + }` + +var gitlabTestGroupsResponse = `[ + { + "id": 1, + "web_url": "https://gitlab.com/groups/example", + "name": "example", + "path": "example", + "description": "", + "visibility": "private", + "lfs_enabled": true, + "avatar_url": null, + "request_access_enabled": true, + "full_name": "example", + "full_path": "example", + "parent_id": null, + "ldap_cn": null, + "ldap_access": null + }, + { + "id": 2, + "web_url": "https://gitlab.com/groups/example/subgroup", + "name": "subgroup", + "path": "subgroup", + "description": "", + "visibility": "private", + "lfs_enabled": true, + "avatar_url": null, + "request_access_enabled": true, + "full_name": "example / subgroup", + "full_path": "example/subgroup", + "parent_id": null, + "ldap_cn": null, + "ldap_access": null + } +]` + +func Test_Gitlab_getUserInfo(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(gitlabTestUserResponse)) + } else if r.URL.Path == "/groups" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(gitlabTestGroupsResponse)) + } + })) + defer server.Close() + + gitlabAPI = server.URL + + u, rawJSON, err := providerGitlab.GetUserInfo(TokenInfo{AccessToken: "secret"}) + NoError(t, err) + Equal(t, "john_smith", u.Sub) + Equal(t, "john@example.com", u.Email) + Equal(t, "John Smith", u.Name) + Equal(t, []string{"example", "example/subgroup"}, u.Groups) + Equal(t, `{"user":`+gitlabTestUserResponse+`,"groups":`+gitlabTestGroupsResponse+`}`, rawJSON) +} + +func Test_Gitlab_getUserInfo_NoServer(t *testing.T) { + gitlabAPI = "http://localhost" + + u, rawJSON, err := providerGitlab.GetUserInfo(TokenInfo{AccessToken: "secret"}) + Equal(t, model.UserInfo{}, u) + Empty(t, rawJSON) + Error(t, err) + Regexp(t, regexp.MustCompile(`connection refused$`), err.Error()) +} + +func Test_Gitlab_getUserInfo_UserContentTypeNegative(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte(gitlabTestUserResponse)) + } else if r.URL.Path == "/groups" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(gitlabTestGroupsResponse)) + } + })) + defer server.Close() + + gitlabAPI = server.URL + + u, rawJSON, err := providerGitlab.GetUserInfo(TokenInfo{AccessToken: "secret"}) + Equal(t, model.UserInfo{}, u) + Empty(t, rawJSON) + Error(t, err) + Regexp(t, regexp.MustCompile(`^wrong content-type on gitlab get user info`), err.Error()) +} + +func Test_Gitlab_getUserInfo_GroupsContentTypeNegative(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(gitlabTestUserResponse)) + } else if r.URL.Path == "/groups" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte(gitlabTestGroupsResponse)) + } + })) + defer server.Close() + + gitlabAPI = server.URL + + u, rawJSON, err := providerGitlab.GetUserInfo(TokenInfo{AccessToken: "secret"}) + Equal(t, model.UserInfo{}, u) + Empty(t, rawJSON) + Error(t, err) + Regexp(t, regexp.MustCompile(`^wrong content-type on gitlab get groups info`), err.Error()) +} + +func Test_Gitlab_getUserInfo_UserStatusCodeNegative(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(gitlabTestUserResponse)) + } else if r.URL.Path == "/groups" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(gitlabTestGroupsResponse)) + } + })) + defer server.Close() + + gitlabAPI = server.URL + + u, rawJSON, err := providerGitlab.GetUserInfo(TokenInfo{AccessToken: "secret"}) + Equal(t, model.UserInfo{}, u) + Empty(t, rawJSON) + Error(t, err) + Regexp(t, regexp.MustCompile(`^got http status [0-9]{3} on gitlab get user info`), err.Error()) +} + +func Test_Gitlab_getUserInfo_GroupsStatusCodeNegative(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(gitlabTestUserResponse)) + } else if r.URL.Path == "/groups" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(gitlabTestGroupsResponse)) + } + })) + defer server.Close() + + gitlabAPI = server.URL + + u, rawJSON, err := providerGitlab.GetUserInfo(TokenInfo{AccessToken: "secret"}) + Equal(t, model.UserInfo{}, u) + Empty(t, rawJSON) + Error(t, err) + Regexp(t, regexp.MustCompile(`^got http status [0-9]{3} on gitlab get groups info`), err.Error()) +} + +func Test_Gitlab_getUserInfo_UserJSONNegative(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte("[]")) + } else if r.URL.Path == "/groups" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(gitlabTestGroupsResponse)) + } + })) + defer server.Close() + + gitlabAPI = server.URL + + u, rawJSON, err := providerGitlab.GetUserInfo(TokenInfo{AccessToken: "secret"}) + Equal(t, model.UserInfo{}, u) + Empty(t, rawJSON) + Error(t, err) + Regexp(t, regexp.MustCompile(`^error parsing gitlab get user info`), err.Error()) +} + +func Test_Gitlab_getUserInfo_GroupsJSONNegative(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(gitlabTestUserResponse)) + } else if r.URL.Path == "/groups" { + Equal(t, "secret", r.FormValue("access_token")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte("{}")) + } + })) + defer server.Close() + + gitlabAPI = server.URL + + u, rawJSON, err := providerGitlab.GetUserInfo(TokenInfo{AccessToken: "secret"}) + Equal(t, model.UserInfo{}, u) + Empty(t, rawJSON) + Error(t, err) + Regexp(t, regexp.MustCompile(`^error parsing gitlab get groups info`), err.Error()) +} diff --git a/oauth2/provider_test.go b/oauth2/provider_test.go @@ -1,8 +1,9 @@ package oauth2 import ( - . "github.com/stretchr/testify/assert" "testing" + + . "github.com/stretchr/testify/assert" ) func Test_ProviderRegistration(t *testing.T) { @@ -22,10 +23,15 @@ func Test_ProviderRegistration(t *testing.T) { NotNil(t, facebook) True(t, exist) + gitlab, exist := GetProvider("gitlab") + NotNil(t, gitlab) + True(t, exist) + list := ProviderList() - Equal(t, 4, len(list)) + Equal(t, 5, len(list)) Contains(t, list, "github") Contains(t, list, "google") Contains(t, list, "bitbucket") Contains(t, list, "facebook") + Contains(t, list, "gitlab") }