diff --git a/src/adminserver/systemcfg/store/database/driver_db.go b/src/adminserver/systemcfg/store/database/driver_db.go index 3a6377e7d..85d76e213 100644 --- a/src/adminserver/systemcfg/store/database/driver_db.go +++ b/src/adminserver/systemcfg/store/database/driver_db.go @@ -15,37 +15,40 @@ package database import ( - "github.com/vmware/harbor/src/common/dao" - "github.com/vmware/harbor/src/common/models" + "fmt" "github.com/vmware/harbor/src/adminserver/systemcfg/store" "github.com/vmware/harbor/src/common" - "fmt" + "github.com/vmware/harbor/src/common/dao" + "github.com/vmware/harbor/src/common/models" "strconv" ) const ( name = "database" - ) -var( +) + +var ( numKeys = map[string]bool{ - common.EmailPort:true, - common.LDAPScope:true, - common.LDAPTimeout:true, - common.TokenExpiration:true, - common.MySQLPort:true, - common.MaxJobWorkers:true, - common.CfgExpiration:true, - common.ClairDBPort:true, - } - boolKeys = map[string]bool{ - common.WithClair:true, - common.WithNotary:true, - common.SelfRegistration:true, - common.EmailSSL:true, - common.EmailInsecure:true, - common.LDAPVerifyCert:true, + common.EmailPort: true, + common.LDAPScope: true, + common.LDAPTimeout: true, + common.TokenExpiration: true, + common.MySQLPort: true, + common.MaxJobWorkers: true, + common.CfgExpiration: true, + common.ClairDBPort: true, } - ) + boolKeys = map[string]bool{ + common.WithClair: true, + common.WithNotary: true, + common.SelfRegistration: true, + common.EmailSSL: true, + common.EmailInsecure: true, + common.LDAPVerifyCert: true, + common.UAAVerifyCert: true, + } +) + type cfgStore struct { name string } @@ -56,14 +59,15 @@ func (c *cfgStore) Name() string { } // NewCfgStore New a cfg store for database driver -func NewCfgStore() (store.Driver, error){ +func NewCfgStore() (store.Driver, error) { return &cfgStore{ name: name, }, nil } + // Read configuration from database func (c *cfgStore) Read() (map[string]interface{}, error) { - configEntries,error := dao.GetConfigEntries() + configEntries, error := dao.GetConfigEntries() if error != nil { return nil, error } @@ -71,53 +75,54 @@ func (c *cfgStore) Read() (map[string]interface{}, error) { } // WrapperConfig Wrapper the configuration -func WrapperConfig (configEntries []*models.ConfigEntry) (map[string]interface{}, error) { +func WrapperConfig(configEntries []*models.ConfigEntry) (map[string]interface{}, error) { config := make(map[string]interface{}) - for _,entry := range configEntries{ - if numKeys[entry.Key]{ + for _, entry := range configEntries { + if numKeys[entry.Key] { strvalue, err := strconv.Atoi(entry.Value) if err != nil { return nil, err } config[entry.Key] = float64(strvalue) - }else if boolKeys[entry.Key] { + } else if boolKeys[entry.Key] { strvalue, err := strconv.ParseBool(entry.Value) if err != nil { return nil, err } - config[entry.Key]=strvalue - }else{ + config[entry.Key] = strvalue + } else { config[entry.Key] = entry.Value } } return config, nil } + // Write save configuration to database func (c *cfgStore) Write(config map[string]interface{}) error { - configEntries ,_:= TranslateConfig(config) + configEntries, _ := TranslateConfig(config) return dao.SaveConfigEntries(configEntries) } // TranslateConfig Translate configuration from int, bool, float64 to string -func TranslateConfig(config map[string]interface{}) ([]models.ConfigEntry,error) { +func TranslateConfig(config map[string]interface{}) ([]models.ConfigEntry, error) { var configEntries []models.ConfigEntry for k, v := range config { var entry = new(models.ConfigEntry) entry.Key = k switch v.(type) { case string: - entry.Value=v.(string) + entry.Value = v.(string) case int: - entry.Value=strconv.Itoa(v.(int)) + entry.Value = strconv.Itoa(v.(int)) case bool: - entry.Value=strconv.FormatBool(v.(bool)) + entry.Value = strconv.FormatBool(v.(bool)) case float64: - entry.Value=strconv.Itoa(int(v.(float64))) + entry.Value = strconv.Itoa(int(v.(float64))) default: return nil, fmt.Errorf("unknown type %v", v) } - configEntries = append(configEntries,*entry) + configEntries = append(configEntries, *entry) } - return configEntries,nil + return configEntries, nil } diff --git a/src/adminserver/systemcfg/systemcfg.go b/src/adminserver/systemcfg/systemcfg.go index 7bfe4d47f..513731f08 100644 --- a/src/adminserver/systemcfg/systemcfg.go +++ b/src/adminserver/systemcfg/systemcfg.go @@ -130,10 +130,10 @@ var ( parse: parseStringToBool, }, common.ClairDBPassword: "CLAIR_DB_PASSWORD", - common.ClairDB: "CLAIR_DB", + common.ClairDB: "CLAIR_DB", common.ClairDBUsername: "CLAIR_DB_USERNAME", - common.ClairDBHost: "CLAIR_DB_HOST", - common.ClairDBPort: "CLAIR_DB_PORT", + common.ClairDBHost: "CLAIR_DB_HOST", + common.ClairDBPort: "CLAIR_DB_PORT", common.UAAEndpoint: "UAA_ENDPOINT", common.UAAClientID: "UAA_CLIENTID", common.UAAClientSecret: "UAA_CLIENTSECRET", @@ -171,7 +171,10 @@ var ( common.UAAEndpoint: "UAA_ENDPOINT", common.UAAClientID: "UAA_CLIENTID", common.UAAClientSecret: "UAA_CLIENTSECRET", - common.UAAVerifyCert: "UAA_VERIFY_CERT", + common.UAAVerifyCert: &parser{ + env: "UAA_VERIFY_CERT", + parse: parseStringToBool, + }, } ) diff --git a/src/common/const.go b/src/common/const.go index ca5f317c4..82be6df84 100644 --- a/src/common/const.go +++ b/src/common/const.go @@ -18,6 +18,7 @@ package common const ( DBAuth = "db_auth" LDAPAuth = "ldap_auth" + UAAAuth = "uaa_auth" ProCrtRestrEveryone = "everyone" ProCrtRestrAdmOnly = "adminonly" LDAPScopeBase = 1 diff --git a/src/common/dao/base.go b/src/common/dao/base.go index 395342cbc..97ee05fe3 100644 --- a/src/common/dao/base.go +++ b/src/common/dao/base.go @@ -116,6 +116,12 @@ func GetOrmer() orm.Ormer { func ClearTable(table string) error { o := GetOrmer() sql := fmt.Sprintf("delete from %s where 1=1", table) + if table == models.ProjectTable { + sql = fmt.Sprintf("delete from %s where project_id > 1", table) + } + if table == models.UserTable { + sql = fmt.Sprintf("delete from %s where user_id > 2", table) + } _, err := o.Raw(sql).Exec() return err } diff --git a/src/common/models/project.go b/src/common/models/project.go index 1228a191b..4864076cf 100644 --- a/src/common/models/project.go +++ b/src/common/models/project.go @@ -19,6 +19,9 @@ import ( "time" ) +// ProjectTable is the table name for project +const ProjectTable = "project" + // Project holds the details of a project. type Project struct { ProjectID int64 `orm:"pk;auto;column(project_id)" json:"project_id"` @@ -174,3 +177,8 @@ type ProjectQueryResult struct { Total int64 Projects []*Project } + +//TableName is required by beego orm to map Project to table project +func (p *Project) TableName() string { + return ProjectTable +} diff --git a/src/common/models/user.go b/src/common/models/user.go index fff2725d5..e46a09cda 100644 --- a/src/common/models/user.go +++ b/src/common/models/user.go @@ -18,6 +18,9 @@ import ( "time" ) +// UserTable is the name of table in DB that holds the user object +const UserTable = "user" + // User holds the details of a user. type User struct { UserID int `orm:"pk;auto;column(user_id)" json:"user_id"` @@ -45,3 +48,8 @@ type UserQuery struct { Email string Pagination *Pagination } + +// TableName ... +func (u *User) TableName() string { + return UserTable +} diff --git a/src/common/utils/uaa/client.go b/src/common/utils/uaa/client.go index 0f6511f5d..8aab55027 100644 --- a/src/common/utils/uaa/client.go +++ b/src/common/utils/uaa/client.go @@ -19,12 +19,25 @@ import ( "crypto/tls" "crypto/x509" "encoding/json" + "fmt" "io/ioutil" "net/http" "strings" "github.com/vmware/harbor/src/common/utils/log" "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +const ( + //TokenURLSuffix ... + TokenURLSuffix = "/oauth/token" + //AuthURLSuffix ... + AuthURLSuffix = "/oauth/authorize" + //UserInfoURLSuffix ... + UserInfoURLSuffix = "/userinfo" + //UsersURLSuffix ... + UsersURLSuffix = "/Users" ) // Client provides funcs to interact with UAA. @@ -33,6 +46,8 @@ type Client interface { PasswordAuth(username, password string) (*oauth2.Token, error) //GetUserInfoByToken send the token to OIDC endpoint to get user info, currently it's also used to validate the token. GetUserInfo(token string) (*UserInfo, error) + //SearchUser searches a user based on user name. + SearchUser(name string) ([]*SearchUserEntry, error) } // ClientConfig values to initialize UAA Client @@ -56,21 +71,43 @@ type UserInfo struct { Email string `json:"email"` } +//SearchUserEmailEntry ... +type SearchUserEmailEntry struct { + Value string `json:"value"` + Primary bool `json:"primary"` +} + +//SearchUserEntry is the struct of an entry of user within search result. +type SearchUserEntry struct { + ID string `json:"id"` + ExtID string `json:"externalId"` + UserName string `json:"userName"` + Emails []SearchUserEmailEntry `json:"emails"` + Groups []interface{} +} + +//SearchUserRes is the struct to parse the result of search user API of UAA +type SearchUserRes struct { + Resources []*SearchUserEntry `json:"resources"` + TotalResults int `json:"totalResults"` + Schemas []string `json:"schemas"` +} + // DefaultClient leverages oauth2 pacakge for oauth features type defaultClient struct { httpClient *http.Client oauth2Cfg *oauth2.Config + twoLegCfg *clientcredentials.Config endpoint string //TODO: add public key, etc... } func (dc *defaultClient) PasswordAuth(username, password string) (*oauth2.Token, error) { - ctx := context.WithValue(context.Background(), oauth2.HTTPClient, dc.httpClient) - return dc.oauth2Cfg.PasswordCredentialsToken(ctx, username, password) + return dc.oauth2Cfg.PasswordCredentialsToken(dc.prepareCtx(), username, password) } func (dc *defaultClient) GetUserInfo(token string) (*UserInfo, error) { - userInfoURL := dc.endpoint + "/uaa/userinfo" + userInfoURL := dc.endpoint + UserInfoURLSuffix req, err := http.NewRequest(http.MethodGet, userInfoURL, nil) if err != nil { return nil, err @@ -92,6 +129,45 @@ func (dc *defaultClient) GetUserInfo(token string) (*UserInfo, error) { return info, nil } +func (dc *defaultClient) SearchUser(username string) ([]*SearchUserEntry, error) { + token, err := dc.twoLegCfg.Token(dc.prepareCtx()) + if err != nil { + return nil, err + } + url := dc.endpoint + UsersURLSuffix + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + q := req.URL.Query() + q.Add("filter", fmt.Sprintf("Username eq '%s'", username)) + req.URL.RawQuery = q.Encode() + token.SetAuthHeader(req) + log.Debugf("request URL: %s", req.URL) + resp, err := dc.httpClient.Do(req) + if err != nil { + return nil, err + } + bytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Unexpected status code for searching user in UAA: %d, response: %s", resp.StatusCode, string(bytes)) + } + res := &SearchUserRes{} + if err := json.Unmarshal(bytes, res); err != nil { + return nil, err + } + return res.Resources, nil +} + +func (dc *defaultClient) prepareCtx() context.Context { + return context.WithValue(context.Background(), oauth2.HTTPClient, dc.httpClient) +} + // NewDefaultClient creates an instance of defaultClient. func NewDefaultClient(cfg *ClientConfig) (Client, error) { url := cfg.Endpoint @@ -125,14 +201,21 @@ func NewDefaultClient(cfg *ClientConfig) (Client, error) { ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Endpoint: oauth2.Endpoint{ - TokenURL: url + "/uaa/oauth/token", - AuthURL: url + "/uaa/oauth/authorize", + TokenURL: url + TokenURLSuffix, + AuthURL: url + AuthURLSuffix, }, } + cc := &clientcredentials.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + TokenURL: url + TokenURLSuffix, + } + return &defaultClient{ httpClient: hc, oauth2Cfg: oc, + twoLegCfg: cc, endpoint: url, }, nil } diff --git a/src/common/utils/uaa/client_test.go b/src/common/utils/uaa/client_test.go index ead0c0273..774392106 100644 --- a/src/common/utils/uaa/client_test.go +++ b/src/common/utils/uaa/client_test.go @@ -30,15 +30,18 @@ func TestMain(m *testing.M) { } } -func TestPasswordAuth(t *testing.T) { - cfg := &ClientConfig{ +func getCfg() *ClientConfig { + return &ClientConfig{ ClientID: "uaa", ClientSecret: "secret", Endpoint: mockUAAServer.URL, SkipTLSVerify: true, } +} + +func TestPasswordAuth(t *testing.T) { assert := assert.New(t) - client, err := NewDefaultClient(cfg) + client, err := NewDefaultClient(getCfg()) assert.Nil(err) _, err = client.PasswordAuth("user1", "pass1") assert.Nil(err) @@ -47,14 +50,8 @@ func TestPasswordAuth(t *testing.T) { } func TestUserInfo(t *testing.T) { - cfg := &ClientConfig{ - ClientID: "uaa", - ClientSecret: "secret", - Endpoint: mockUAAServer.URL, - SkipTLSVerify: true, - } assert := assert.New(t) - client, err := NewDefaultClient(cfg) + client, err := NewDefaultClient(getCfg()) assert.Nil(err) token, err := ioutil.ReadFile(path.Join(currPath(), "test", "./good-access-token.txt")) if err != nil { @@ -68,6 +65,21 @@ func TestUserInfo(t *testing.T) { assert.NotNil(err2) } +func TestSearchUser(t *testing.T) { + assert := assert.New(t) + client, err := NewDefaultClient(getCfg()) + assert.Nil(err) + res1, err := client.SearchUser("one") + assert.Nil(err) + assert.Equal(1, len(res1)) + if len(res1) == 1 { + assert.Equal("one", res1[0].UserName) + } + res2, err := client.SearchUser("none") + assert.Nil(err) + assert.Equal(0, len(res2)) +} + func currPath() string { _, f, _, ok := runtime.Caller(0) if !ok { diff --git a/src/common/utils/uaa/fake_client.go b/src/common/utils/uaa/fake_client.go index 6e02bb1b6..0f0af38a4 100644 --- a/src/common/utils/uaa/fake_client.go +++ b/src/common/utils/uaa/fake_client.go @@ -37,3 +37,40 @@ func (fc *FakeClient) PasswordAuth(username, password string) (*oauth2.Token, er func (fc *FakeClient) GetUserInfo(token string) (*UserInfo, error) { return nil, nil } + +// SearchUser ... +func (fc *FakeClient) SearchUser(name string) ([]*SearchUserEntry, error) { + res := []*SearchUserEntry{} + entryOne := &SearchUserEntry{ + ExtID: "some-external-id-1", + ID: "u-0001", + UserName: "one", + Emails: []SearchUserEmailEntry{SearchUserEmailEntry{ + Primary: false, + Value: "one@email.com", + }}, + } + entryTwoA := &SearchUserEntry{ + ExtID: "some-external-id-2-a", + ID: "u-0002a", + UserName: "two", + Emails: []SearchUserEmailEntry{SearchUserEmailEntry{ + Primary: false, + Value: "two@email.com", + }}, + } + entryTwoB := &SearchUserEntry{ + ExtID: "some-external-id-2-b", + ID: "u-0002b", + UserName: "two", + } + if name == "one" { + res = append(res, entryOne) + } else if name == "two" { + res = append(res, entryTwoA) + res = append(res, entryTwoB) + } else if name == "error" { + return res, fmt.Errorf("some error") + } + return res, nil +} diff --git a/src/common/utils/uaa/test/good-access-token.txt b/src/common/utils/uaa/test/good-access-token.txt index 2c63bb1d2..cc0fd29b2 100644 --- a/src/common/utils/uaa/test/good-access-token.txt +++ b/src/common/utils/uaa/test/good-access-token.txt @@ -1 +1 @@ -eyJSUzI1NiIsImtpZCI6ImxlZ2FjeS10b2tlbi1rZXkiLCJ0eXAiOiJKV1QifQ.eyJqdGkiOiIyNmRjYjg1YzMzZjU0OGM5ODk2YjI4MDEwN2IyOWM0NiIsInN1YiI6IjlhMTM0ODhmLWYzY2YtNDdhNi05OGYwLTRmZWQyMWY0MzUyMCIsInNjb3BlIjpbIm9wZW5pZCJdLCJjbGllbnRfaWQiOiJrdWJlcm5ldGVzIiwiY2lkIjoia3ViZXJuZXRlcyIsImF6cCI6Imt1YmVybmV0ZXMiLCJncmFudF90eXBlIjoicGFzc3dvcmQiLCJ1c2VyX2lkIjoiOWExMzQ4OGYtZjNjZi00N2E2LTk4ZjAtNGZlZDIxZjQzNTIwIiwib3JpZ2luIjoibGRhcCIsInVzZXJfbmFtZSI6InVzZXIwMSIsImVtYWlsIjoidXNlcjAxQHVzZXIuZnJvbS5sZGFwLmNmIiwiYXV0aF90aW1lIjoxNTExNDA1NDEwLCJyZXZfc2lnIjoiOGEwYmY5OWQiLCJpYXQiOjE1MTE0MDU0MTAsImV4cCI6MTUxMTQ0ODYxMCwiaXNzIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6ODQ0My91YWEvb2F1dGgvdG9rZW4iLCJ6aWQiOiJ1YWEiLCJhdWQiOlsia3ViZXJuZXRlcyIsIm9wZW5pZCJdfQ.I7VBx_cQoYkotRJ8KdmESAf_xjzp-R44BRz9ngHPUnoqr4rSMin-Ful8wNzEnaYaG56_mrIPuLOb6vXGWW1svRU892GOK9WQRSiFp7O81V7f1bH6JXnIGvyBNl3JOkDB9d5wXn137h9vNKq3Z9TF3jD7oXR_OENS8paclW5EAjmjGvEVIhObMmHCLhsJshTWIoP8AwoP1m9iqak_-t0c99HWaf1AgVUtT2i9Jb63ndJGA6BkOSRH_YxXmM_qtXmk_0kRA5oLDR2UGA4TVXCYp1_8iwQYjvGBVxO24I5jJh_zDYs5YLTFeNzMTPEhAl_Te6NiE91gRXq6KiVk9tTfuA +eyJhbGciOiJSUzI1NiIsImtpZCI6ImxlZ2FjeS10b2tlbi1rZXkiLCJ0eXAiOiJKV1QifQ.eyJqdGkiOiIyNzlhNmI2MTRhMzM0NjVjYjYxZTM4ZmY5YTc4Y2YxZSIsInN1YiI6IjIwMTExNzE5LWNlM2EtNDRhYS05MmFjLTE3NmM0ZTM4MWY2NiIsInNjb3BlIjpbIm9wZW5pZCJdLCJjbGllbnRfaWQiOiJrdWJlcm5ldGVzIiwiY2lkIjoia3ViZXJuZXRlcyIsImF6cCI6Imt1YmVybmV0ZXMiLCJncmFudF90eXBlIjoicGFzc3dvcmQiLCJ1c2VyX2lkIjoiMjAxMTE3MTktY2UzYS00NGFhLTkyYWMtMTc2YzRlMzgxZjY2Iiwib3JpZ2luIjoibGRhcCIsInVzZXJfbmFtZSI6ImFkbWluIiwiZW1haWwiOiJhZG1pbkB1c2VyLmZyb20ubGRhcC5jZiIsImF1dGhfdGltZSI6MTUwNjg1MDQyNiwicmV2X3NpZyI6IjZkOWNlN2UwIiwiaWF0IjoxNTA2ODUwNDI2LCJleHAiOjE1MDY4OTM2MjYsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0Ojg0NDMvdWFhL29hdXRoL3Rva2VuIiwiemlkIjoidWFhIiwiYXVkIjpbImt1YmVybmV0ZXMiLCJvcGVuaWQiXX0.Ni2yJ7Gp6OnEdhcWyfGeCm1yG_rqQgf9BA0raJ37hdH-_ZRQ4HIELkWLv3gGPuWPV4HX6EKKerjJWXCPKihyiIIVT-W7VkwFMdDv9e4aA_h2eXjxHeUdjl0Cgw7gSAPSmm_QtkeLPuj15Ngd31yiuBoxy49_sjCyn3hjd8LP2ENEVtpk2vcCiQigW-YWbDaG64im1IP6jjRruwRdPF0Idjf4vuimFG-tiRdauDvnZc90W5fIJ3AUFW_ryGnSvc7E0rBZFYOgD5BB_3HLmWzB64-D3AVe9h5wQXOBorEaXLlSXfm16RQHFI_duSh3YOZUjHLuUYIRCuKaK5RPi0Fztg diff --git a/src/common/utils/uaa/test/no-user.json b/src/common/utils/uaa/test/no-user.json new file mode 100644 index 000000000..a59e370e2 --- /dev/null +++ b/src/common/utils/uaa/test/no-user.json @@ -0,0 +1 @@ +{"resources":[],"startIndex":1,"itemsPerPage":100,"totalResults":0,"schemas":["urn:scim:schemas:core:1.0"]} diff --git a/src/common/utils/uaa/test/one-user.json b/src/common/utils/uaa/test/one-user.json new file mode 100644 index 000000000..6147d7a40 --- /dev/null +++ b/src/common/utils/uaa/test/one-user.json @@ -0,0 +1 @@ +{"resources":[{"id":"6af888a1-92fa-4a30-82dd-4db28f2e15f0","externalId":"cn=one,dc=vmware,dc=com","meta":{"version":0,"created":"2017-12-20T22:54:34.493Z","lastModified":"2017-12-20T22:54:34.493Z"},"userName":"one","name":{},"emails":[{"value":"one@example.com","primary":false}],"groups":[{"value":"546a79d3-609b-49df-8111-56eee574fc99","display":"roles","type":"DIRECT"},{"value":"7e5eb7dc-8067-424a-a593-8620e9ef4962","display":"approvals.me","type":"DIRECT"},{"value":"9a946687-7be7-4a79-9742-462ae52e4833","display":"password.write","type":"DIRECT"},{"value":"f263a309-f855-405b-bcc4-e1c7453420c3","display":"uaa.offline_token","type":"DIRECT"},{"value":"80898c93-64a8-46cc-ba15-37fec9e2e56d","display":"uaa.user","type":"DIRECT"},{"value":"f8605b49-0dbc-47cf-a993-9691b7e313ab","display":"scim.userids","type":"DIRECT"},{"value":"83237f80-e709-40b9-8599-ab08b0f141a9","display":"oauth.approvals","type":"DIRECT"},{"value":"f685504d-c760-41cf-9c26-da8edddf643e","display":"user_attributes","type":"DIRECT"},{"value":"4243ba6a-001f-4052-8059-ada841a14e62","display":"cloud_controller.write","type":"DIRECT"},{"value":"36a94fb1-3bd2-4db6-8246-8a00256b080f","display":"profile","type":"DIRECT"},{"value":"0ead714c-02f1-403b-bd24-950089772f47","display":"scim.me","type":"DIRECT"},{"value":"a0944e04-1007-43ba-9745-e1ed62de21f5","display":"cloud_controller.read","type":"DIRECT"},{"value":"e9f2b839-9e2d-45b4-9179-5fce07cd013b","display":"cloud_controller_service_permissions.read","type":"DIRECT"},{"value":"2bb835d6-c62d-477b-a1df-780bb3ec560b","display":"openid","type":"DIRECT"}],"approvals":[],"active":true,"verified":true,"origin":"ldap","zoneId":"uaa","passwordLastModified":"2017-12-20T22:54:34.000Z","lastLogonTime":1513839274546,"schemas":["urn:scim:schemas:core:1.0"]}],"startIndex":1,"itemsPerPage":100,"totalResults":1,"schemas":["urn:scim:schemas:core:1.0"]} diff --git a/src/common/utils/uaa/test/server.go b/src/common/utils/uaa/test/server.go index 5f6e1737c..07f4dc34d 100644 --- a/src/common/utils/uaa/test/server.go +++ b/src/common/utils/uaa/test/server.go @@ -53,23 +53,36 @@ func (t *tokenHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { http.Error(rw, "invalid client id/secret in header", http.StatusUnauthorized) return } - if gt := req.FormValue("grant_type"); gt != "password" { + gt := req.FormValue("grant_type") + if gt == "password" { + reqUsername := req.FormValue("username") + reqPasswd := req.FormValue("password") + if reqUsername == t.username && reqPasswd == t.password { + serveToken(rw) + } else { + http.Error(rw, fmt.Sprintf("invalid username/password %s/%s", reqUsername, reqPasswd), http.StatusUnauthorized) + } + } else if gt == "client_credentials" { + serveToken(rw) + } else { http.Error(rw, fmt.Sprintf("invalid grant_type: %s", gt), http.StatusBadRequest) return } - reqUsername := req.FormValue("username") - reqPasswd := req.FormValue("password") - if reqUsername == t.username && reqPasswd == t.password { - token, err := ioutil.ReadFile(path.Join(currPath(), "./uaa-token.json")) - if err != nil { - panic(err) - } - _, err2 := rw.Write(token) - if err2 != nil { - panic(err2) - } - } else { - http.Error(rw, fmt.Sprintf("invalid username/password %s/%s", reqUsername, reqPasswd), http.StatusUnauthorized) +} + +func serveToken(rw http.ResponseWriter) { + serveJSONFile(rw, "uaa-token.json") +} + +func serveJSONFile(rw http.ResponseWriter, filename string) { + data, err := ioutil.ReadFile(path.Join(currPath(), filename)) + if err != nil { + panic(err) + } + rw.Header().Add("Content-Type", "application/json") + _, err2 := rw.Write(data) + if err2 != nil { + panic(err2) } } @@ -78,27 +91,52 @@ type userInfoHandler struct { } func (u *userInfoHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - v := req.Header.Get("Authorization") + v := req.Header.Get("authorization") prefix := v[0:7] reqToken := v[7:] if strings.ToLower(prefix) != "bearer " || reqToken != u.token { http.Error(rw, "invalid token", http.StatusUnauthorized) return } - userInfo, err := ioutil.ReadFile(path.Join(currPath(), "./user-info.json")) - if err != nil { - panic(err) + serveJSONFile(rw, "./user-info.json") +} + +type searchUserHandler struct { + token string +} + +func (su *searchUserHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + v := req.Header.Get("authorization") + if v == "" { + v = req.Header.Get("Authorization") } - _, err2 := rw.Write(userInfo) - if err2 != nil { - panic(err2) + prefix := v[0:7] + reqToken := v[7:] + if strings.ToLower(prefix) != "bearer " || reqToken != su.token { + http.Error(rw, "invalid token", http.StatusUnauthorized) + return } + f := req.URL.Query().Get("filter") + elements := strings.Split(f, " ") + if len(elements) == 3 { + if elements[0] == "Username" && elements[1] == "eq" { + if elements[2] == "'one'" { + serveJSONFile(rw, "one-user.json") + return + } + serveJSONFile(rw, "no-user.json") + return + } + http.Error(rw, "invalid request", http.StatusBadRequest) + return + } + http.Error(rw, fmt.Sprintf("Invalid request, elements: %v", elements), http.StatusBadRequest) } // NewMockServer ... func NewMockServer(cfg *MockServerConfig) *httptest.Server { mux := http.NewServeMux() - mux.Handle("/uaa/oauth/token", &tokenHandler{ + mux.Handle("/oauth/token", &tokenHandler{ cfg.ClientID, cfg.ClientSecret, cfg.Username, @@ -108,6 +146,7 @@ func NewMockServer(cfg *MockServerConfig) *httptest.Server { if err != nil { panic(err) } - mux.Handle("/uaa/userinfo", &userInfoHandler{strings.TrimSpace(string(token))}) + mux.Handle("/userinfo", &userInfoHandler{strings.TrimSpace(string(token))}) + mux.Handle("/Users", &searchUserHandler{strings.TrimSpace(string(token))}) return httptest.NewTLSServer(mux) } diff --git a/src/common/utils/uaa/test/uaa-token.json b/src/common/utils/uaa/test/uaa-token.json index 3dc66d6db..8a4d52e8d 100644 --- a/src/common/utils/uaa/test/uaa-token.json +++ b/src/common/utils/uaa/test/uaa-token.json @@ -1 +1 @@ -{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImxlZ2FjeS10b2tlbi1rZXkiLCJ0eXAiOiJKV1QifQ.eyJqdGkiOiIyNzlhNmI2MTRhMzM0NjVjYjYxZTM4ZmY5YTc4Y2YxZSIsInN1YiI6IjIwMTExNzE5LWNlM2EtNDRhYS05MmFjLTE3NmM0ZTM4MWY2NiIsInNjb3BlIjpbIm9wZW5pZCJdLCJjbGllbnRfaWQiOiJrdWJlcm5ldGVzIiwiY2lkIjoia3ViZXJuZXRlcyIsImF6cCI6Imt1YmVybmV0ZXMiLCJncmFudF90eXBlIjoicGFzc3dvcmQiLCJ1c2VyX2lkIjoiMjAxMTE3MTktY2UzYS00NGFhLTkyYWMtMTc2YzRlMzgxZjY2Iiwib3JpZ2luIjoibGRhcCIsInVzZXJfbmFtZSI6ImFkbWluIiwiZW1haWwiOiJhZG1pbkB1c2VyLmZyb20ubGRhcC5jZiIsImF1dGhfdGltZSI6MTUwNjg1MDQyNiwicmV2X3NpZyI6IjZkOWNlN2UwIiwiaWF0IjoxNTA2ODUwNDI2LCJleHAiOjE1MDY4OTM2MjYsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0Ojg0NDMvdWFhL29hdXRoL3Rva2VuIiwiemlkIjoidWFhIiwiYXVkIjpbImt1YmVybmV0ZXMiLCJvcGVuaWQiXX0.Ni2yJ7Gp6OnEdhcWyfGeCm1yG_rqQgf9BA0raJ37hdH-_ZRQ4HIELkWLv3gGPuWPV4HX6EKKerjJWXCPKihyiIIVT-W7VkwFMdDv9e4aA_h2eXjxHeUdjl0Cgw7gSAPSmm_QtkeLPuj15Ngd31yiuBoxy49_sjCyn3hjd8LP2ENEVtpk2vcCiQigW-YWbDaG64im1IP6jjRruwRdPF0Idjf4vuimFG-tiRdauDvnZc90W5fIJ3AUFW_ryGnSvc7E0rBZFYOgD5BB_3HLmWzB64-D3AVe9h5wQXOBorEaXLlSXfm16RQHFI_duSh3YOZUjHLuUYIRCuKaK5RPi0Fztg","token_type":"bearer","refresh_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImxlZ2FjeS10b2tlbi1rZXkiLCJ0eXAiOiJKV1QifQ.eyJqdGkiOiJkZTU0ZjJkMDlkODc0ZTliOTExZDk4YWQ1MTQzMjljZC1yIiwic3ViIjoiMjAxMTE3MTktY2UzYS00NGFhLTkyYWMtMTc2YzRlMzgxZjY2Iiwic2NvcGUiOlsib3BlbmlkIl0sImlhdCI6MTUwNjg1MDQyNiwiZXhwIjoxNTA5NDQyNDI2LCJjaWQiOiJrdWJlcm5ldGVzIiwiY2xpZW50X2lkIjoia3ViZXJuZXRlcyIsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0Ojg0NDMvdWFhL29hdXRoL3Rva2VuIiwiemlkIjoidWFhIiwiZ3JhbnRfdHlwZSI6InBhc3N3b3JkIiwidXNlcl9uYW1lIjoiYWRtaW4iLCJvcmlnaW4iOiJsZGFwIiwidXNlcl9pZCI6IjIwMTExNzE5LWNlM2EtNDRhYS05MmFjLTE3NmM0ZTM4MWY2NiIsInJldl9zaWciOiI2ZDljZTdlMCIsImF1ZCI6WyJrdWJlcm5ldGVzIiwib3BlbmlkIl19.oW4xK3QBjMtjUH_AWWyO6A0QwbIbTwrEFnc-hulj3QbLoULvC2V3L53rcKhT1gOtj8aaQTZFdBEQNGjBpzjFU8bpwxb0szyPMkc5PjXjcJGltL3MvmBf3P0TuUxJU9vP3FjrvwwueNAafLAyRIHy8yA3ZngzkL8KCI0ps51gCRU2oOe9hGDv2ZrsZ21u760hFGiRq5-7HWJu3VMqhMVRkUyPD_3j9AGZr6gf3o_7S9oJYwEDxPZaBhhVZI6QHeQNa07w7jCqTX97_fcpeTMbrBJiz_5yD9-kJZneI4xzAMIyNwAcbSJYrL7WZ2H01heGwWFEkrrv68YUJ762jB4WAw","expires_in":43199,"scope":"openid","jti":"279a6b614a33465cb61e38ff9a78cf1e"} \ No newline at end of file +{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImxlZ2FjeS10b2tlbi1rZXkiLCJ0eXAiOiJKV1QifQ.eyJqdGkiOiIyNzlhNmI2MTRhMzM0NjVjYjYxZTM4ZmY5YTc4Y2YxZSIsInN1YiI6IjIwMTExNzE5LWNlM2EtNDRhYS05MmFjLTE3NmM0ZTM4MWY2NiIsInNjb3BlIjpbIm9wZW5pZCJdLCJjbGllbnRfaWQiOiJrdWJlcm5ldGVzIiwiY2lkIjoia3ViZXJuZXRlcyIsImF6cCI6Imt1YmVybmV0ZXMiLCJncmFudF90eXBlIjoicGFzc3dvcmQiLCJ1c2VyX2lkIjoiMjAxMTE3MTktY2UzYS00NGFhLTkyYWMtMTc2YzRlMzgxZjY2Iiwib3JpZ2luIjoibGRhcCIsInVzZXJfbmFtZSI6ImFkbWluIiwiZW1haWwiOiJhZG1pbkB1c2VyLmZyb20ubGRhcC5jZiIsImF1dGhfdGltZSI6MTUwNjg1MDQyNiwicmV2X3NpZyI6IjZkOWNlN2UwIiwiaWF0IjoxNTA2ODUwNDI2LCJleHAiOjE1MDY4OTM2MjYsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0Ojg0NDMvdWFhL29hdXRoL3Rva2VuIiwiemlkIjoidWFhIiwiYXVkIjpbImt1YmVybmV0ZXMiLCJvcGVuaWQiXX0.Ni2yJ7Gp6OnEdhcWyfGeCm1yG_rqQgf9BA0raJ37hdH-_ZRQ4HIELkWLv3gGPuWPV4HX6EKKerjJWXCPKihyiIIVT-W7VkwFMdDv9e4aA_h2eXjxHeUdjl0Cgw7gSAPSmm_QtkeLPuj15Ngd31yiuBoxy49_sjCyn3hjd8LP2ENEVtpk2vcCiQigW-YWbDaG64im1IP6jjRruwRdPF0Idjf4vuimFG-tiRdauDvnZc90W5fIJ3AUFW_ryGnSvc7E0rBZFYOgD5BB_3HLmWzB64-D3AVe9h5wQXOBorEaXLlSXfm16RQHFI_duSh3YOZUjHLuUYIRCuKaK5RPi0Fztg","token_type":"bearer","refresh_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImxlZ2FjeS10b2tlbi1rZXkiLCJ0eXAiOiJKV1QifQ.eyJqdGkiOiJkZTU0ZjJkMDlkODc0ZTliOTExZDk4YWQ1MTQzMjljZC1yIiwic3ViIjoiMjAxMTE3MTktY2UzYS00NGFhLTkyYWMtMTc2YzRlMzgxZjY2Iiwic2NvcGUiOlsib3BlbmlkIl0sImlhdCI6MTUwNjg1MDQyNiwiZXhwIjoxNTA5NDQyNDI2LCJjaWQiOiJrdWJlcm5ldGVzIiwiY2xpZW50X2lkIjoia3ViZXJuZXRlcyIsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0Ojg0NDMvdWFhL29hdXRoL3Rva2VuIiwiemlkIjoidWFhIiwiZ3JhbnRfdHlwZSI6InBhc3N3b3JkIiwidXNlcl9uYW1lIjoiYWRtaW4iLCJvcmlnaW4iOiJsZGFwIiwidXNlcl9pZCI6IjIwMTExNzE5LWNlM2EtNDRhYS05MmFjLTE3NmM0ZTM4MWY2NiIsInJldl9zaWciOiI2ZDljZTdlMCIsImF1ZCI6WyJrdWJlcm5ldGVzIiwib3BlbmlkIl19.oW4xK3QBjMtjUH_AWWyO6A0QwbIbTwrEFnc-hulj3QbLoULvC2V3L53rcKhT1gOtj8aaQTZFdBEQNGjBpzjFU8bpwxb0szyPMkc5PjXjcJGltL3MvmBf3P0TuUxJU9vP3FjrvwwueNAafLAyRIHy8yA3ZngzkL8KCI0ps51gCRU2oOe9hGDv2ZrsZ21u760hFGiRq5-7HWJu3VMqhMVRkUyPD_3j9AGZr6gf3o_7S9oJYwEDxPZaBhhVZI6QHeQNa07w7jCqTX97_fcpeTMbrBJiz_5yD9-kJZneI4xzAMIyNwAcbSJYrL7WZ2H01heGwWFEkrrv68YUJ762jB4WAw","expires_in":43199,"jti":"279a6b614a33465cb61e38ff9a78cf1e", "scope":"clients.read password.write clients.secret uaa.resource openid clients.write uaa.admin scim.write scim.read client_id"} diff --git a/src/ui/auth/authenticator.go b/src/ui/auth/authenticator.go index d58fa4ac7..111f001aa 100644 --- a/src/ui/auth/authenticator.go +++ b/src/ui/auth/authenticator.go @@ -46,10 +46,11 @@ var registry = make(map[string]AuthenticateHelper) // Register add different authenticators to registry map. func Register(name string, h AuthenticateHelper) { if _, dup := registry[name]; dup { - log.Infof("authenticator: %s has been registered", name) + log.Infof("authenticator: %s has been registered,skip", name) return } registry[name] = h + log.Debugf("Registered authencation helper for auth mode: %s", name) } // Login authenticates user credentials based on setting. diff --git a/src/ui/auth/uaa/uaa.go b/src/ui/auth/uaa/uaa.go index b56897bc0..22557a9bf 100644 --- a/src/ui/auth/uaa/uaa.go +++ b/src/ui/auth/uaa/uaa.go @@ -15,24 +15,20 @@ package uaa import ( + "fmt" + "strings" "sync" + "github.com/vmware/harbor/src/common" "github.com/vmware/harbor/src/common/dao" "github.com/vmware/harbor/src/common/models" "github.com/vmware/harbor/src/common/utils/uaa" + "github.com/vmware/harbor/src/ui/auth" "github.com/vmware/harbor/src/ui/config" ) -var lock = &sync.Mutex{} -var client uaa.Client - -//GetClient returns the client instance, if the client is not created it creates one. -func GetClient() (uaa.Client, error) { - lock.Lock() - defer lock.Unlock() - if client != nil { - return client, nil - } +//CreateClient create a UAA Client instance based on system configuration. +func CreateClient() (uaa.Client, error) { UAASettings, err := config.UAASettings() if err != nil { return nil, err @@ -43,51 +39,94 @@ func GetClient() (uaa.Client, error) { Endpoint: UAASettings.Endpoint, SkipTLSVerify: !UAASettings.VerifyCert, } - client, err = uaa.NewDefaultClient(cfg) - return client, err + return uaa.NewDefaultClient(cfg) } -func doAuth(username, password string, client uaa.Client) (*models.User, error) { - t, err := client.PasswordAuth(username, password) +// Auth is the implementation of AuthenticateHelper to access uaa for authentication. +type Auth struct { + sync.Mutex + client uaa.Client +} + +//Authenticate ... +func (u *Auth) Authenticate(m models.AuthModel) (*models.User, error) { + if err := u.ensureClient(); err != nil { + return nil, err + } + t, err := u.client.PasswordAuth(m.Principal, m.Password) if t != nil && err == nil { //TODO: See if it's possible to get more information from token. - u := &models.User{ - Username: username, - Password: "1234567ab", - Email: username + "@placeholder.com", - Realname: username, - } - err = dao.OnBoardUser(u) - if err == nil { - return u, nil + user := &models.User{ + Username: m.Principal, } + err = u.OnBoardUser(user) + return user, err } return nil, err } -// Auth is the implementation of AuthenticateHelper to access uaa for authentication. -type Auth struct{} +// OnBoardUser will check if a user exists in user table, if not insert the user and +// put the id in the pointer of user model, if it does exist, return the user's profile. +func (u *Auth) OnBoardUser(user *models.User) error { + user.Username = strings.TrimSpace(user.Username) + if len(user.Username) == 0 { + return fmt.Errorf("The Username is empty") + } + if len(user.Password) == 0 { + user.Password = "1234567ab" + } + if len(user.Realname) == 0 { + user.Realname = user.Username + } + if len(user.Email) == 0 { + //TODO: handle the case when user.Username itself is an email address. + user.Email = user.Username + "@uaa.placeholder" + } + user.Comment = "From UAA" + return dao.OnBoardUser(user) +} -//Authenticate ... -func (u *Auth) Authenticate(m models.AuthModel) (*models.User, error) { - client, err := GetClient() +// SearchUser search user on uaa server, transform it to Harbor's user model +func (u *Auth) SearchUser(username string) (*models.User, error) { + if err := u.ensureClient(); err != nil { + return nil, err + } + l, err := u.client.SearchUser(username) if err != nil { return nil, err } - return doAuth(m.Principal, m.Password, client) + if len(l) == 0 { + return nil, nil + } + if len(l) > 1 { + return nil, fmt.Errorf("Multiple entries found for username: %s", username) + } + e := l[0] + email := "" + if len(e.Emails) > 0 { + email = e.Emails[0].Value + } + return &models.User{ + Username: username, + Email: email, + }, nil } -// OnBoardUser will check if a user exists in user table, if not insert the user and -// put the id in the pointer of user model, if it does exist, return the user's profile. -// func (u *Auth) OnBoardUser(user *models.User) error { -// panic("not implemented") -// } - -// // SearchUser - search user on uaa server -// func (u *Auth) SearchUser(username string) (*models.User, error) { -// panic("not implemented") -// } - -// func init() { -// auth.Register(auth.UAAAuth, &Auth{}) -// } +func (u *Auth) ensureClient() error { + if u.client != nil { + return nil + } + u.Lock() + defer u.Unlock() + if u.client == nil { + c, err := CreateClient() + if err != nil { + return err + } + u.client = c + } + return nil +} +func init() { + auth.Register(common.UAAAuth, &Auth{}) +} diff --git a/src/ui/auth/uaa/uaa_test.go b/src/ui/auth/uaa/uaa_test.go index 748c362c1..fa25f8436 100644 --- a/src/ui/auth/uaa/uaa_test.go +++ b/src/ui/auth/uaa/uaa_test.go @@ -17,45 +17,159 @@ package uaa import ( "github.com/stretchr/testify/assert" "github.com/vmware/harbor/src/common/dao" + "github.com/vmware/harbor/src/common/models" + "github.com/vmware/harbor/src/common/utils/log" utilstest "github.com/vmware/harbor/src/common/utils/test" "github.com/vmware/harbor/src/common/utils/uaa" "github.com/vmware/harbor/src/ui/config" "os" + "strconv" "testing" ) -func TestGetClient(t *testing.T) { - assert := assert.New(t) +func TestMain(m *testing.M) { + dbHost := os.Getenv("MYSQL_HOST") + if len(dbHost) == 0 { + log.Fatalf("environment variable MYSQL_HOST is not set") + } + dbUser := os.Getenv("MYSQL_USR") + if len(dbUser) == 0 { + log.Fatalf("environment variable MYSQL_USR is not set") + } + dbPortStr := os.Getenv("MYSQL_PORT") + if len(dbPortStr) == 0 { + log.Fatalf("environment variable MYSQL_PORT is not set") + } + dbPort, err := strconv.Atoi(dbPortStr) + if err != nil { + log.Fatalf("invalid MYSQL_PORT: %v", err) + } + + dbPassword := os.Getenv("MYSQL_PWD") + dbDatabase := os.Getenv("MYSQL_DATABASE") + if len(dbDatabase) == 0 { + log.Fatalf("environment variable MYSQL_DATABASE is not set") + } + + database := &models.Database{ + Type: "mysql", + MySQL: &models.MySQL{ + Host: dbHost, + Port: dbPort, + Username: dbUser, + Password: dbPassword, + Database: dbDatabase, + }, + } + dao.InitDatabase(database) server, err := utilstest.NewAdminserver(nil) if err != nil { - t.Fatalf("failed to create a mock admin server: %v", err) + panic(err) } defer server.Close() if err := os.Setenv("ADMINSERVER_URL", server.URL); err != nil { - t.Fatalf("failed to set env %s: %v", "ADMINSERVER_URL", err) + panic(err) } err = config.Init() if err != nil { - t.Fatalf("failed to init config: %v", err) + panic(err) } - c, err := GetClient() + + err = dao.ClearTable("project_member") + if err != nil { + panic(err) + } + err = dao.ClearTable("project_metadata") + if err != nil { + panic(err) + } + err = dao.ClearTable("access_log") + if err != nil { + panic(err) + } + err = dao.ClearTable("project") + if err != nil { + panic(err) + } + err = dao.ClearTable("user") + if err != nil { + panic(err) + } + + rc := m.Run() + os.Exit(rc) +} + +func TestCreateClient(t *testing.T) { + assert := assert.New(t) + c, err := CreateClient() assert.Nil(err) assert.NotNil(c) } -func TestDoAuth(t *testing.T) { +func TestAuthenticate(t *testing.T) { assert := assert.New(t) client := &uaa.FakeClient{ Username: "user1", Password: "password1", } - dao.PrepareTestForMySQL() - u1, err1 := doAuth("user1", "password1", client) + auth := Auth{client: client} + m1 := models.AuthModel{ + Principal: "user1", + Password: "password1", + } + u1, err1 := auth.Authenticate(m1) assert.Nil(err1) - assert.True(u1.UserID > 0) - u2, err2 := doAuth("wrong", "wrong", client) + assert.NotNil(u1) + m2 := models.AuthModel{ + Principal: "wrong", + Password: "wrong", + } + u2, err2 := auth.Authenticate(m2) assert.NotNil(err2) assert.Nil(u2) + err3 := dao.ClearTable(models.UserTable) + assert.Nil(err3) +} + +func TestOnBoardUser(t *testing.T) { + assert := assert.New(t) + auth := Auth{} + um1 := &models.User{ + Username: " ", + } + err1 := auth.OnBoardUser(um1) + assert.NotNil(err1) + um2 := &models.User{ + Username: "test ", + } + user2, _ := dao.GetUser(models.User{Username: "test"}) + assert.Nil(user2) + err2 := auth.OnBoardUser(um2) + assert.Nil(err2) + user, _ := dao.GetUser(models.User{Username: "test"}) + assert.Equal("test", user.Realname) + assert.Equal("test", user.Username) + assert.Equal("test@uaa.placeholder", user.Email) +} + +func TestSearchUser(t *testing.T) { + assert := assert.New(t) + client := &uaa.FakeClient{ + Username: "user1", + Password: "password1", + } + auth := Auth{client: client} + _, err0 := auth.SearchUser("error") + assert.NotNil(err0) + u1, err1 := auth.SearchUser("one") + assert.Nil(err1) + assert.Equal("one@email.com", u1.Email) + _, err2 := auth.SearchUser("two") + assert.NotNil(err2) + user3, err3 := auth.SearchUser("none") + assert.Nil(user3) + assert.Nil(err3) }