diff --git a/.travis.yml b/.travis.yml index 934e32e1..c5272a0b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,13 +13,18 @@ services: - docker env: - - DEX_POSTGRES_DATABASE=postgres DEX_POSTGRES_USER=postgres DEX_POSTGRES_HOST="localhost" DEX_ETCD_ENDPOINTS=http://localhost:2379 DEX_LDAP_TESTS=1 DEBIAN_FRONTEND=noninteractive + - DEX_POSTGRES_DATABASE=postgres DEX_POSTGRES_USER=postgres DEX_POSTGRES_HOST="localhost" DEX_ETCD_ENDPOINTS=http://localhost:2379 DEX_LDAP_TESTS=1 DEBIAN_FRONTEND=noninteractive DEX_KEYSTONE_URL=http://localhost:5000 DEX_KEYSTONE_ADMIN_URL=http://localhost:35357 DEX_KEYSTONE_ADMIN_USER=demo DEX_KEYSTONE_ADMIN_PASS=DEMO_PASS install: - sudo -E apt-get install -y --force-yes slapd time ldap-utils - sudo /etc/init.d/slapd stop - docker run -d --net=host gcr.io/etcd-development/etcd:v3.2.9 - + - docker run -d -p 0.0.0.0:5000:5000 -p 0.0.0.0:35357:35357 openio/openstack-keystone:pike + - | + until curl --fail http://localhost:5000/v3; do + echo 'Waiting for keystone...' + sleep 1; + done; script: - make testall diff --git a/connector/keystone/keystone.go b/connector/keystone/keystone.go new file mode 100644 index 00000000..a86e957d --- /dev/null +++ b/connector/keystone/keystone.go @@ -0,0 +1,271 @@ +// Package keystone provides authentication strategy using Keystone. +package keystone + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/sirupsen/logrus" + + "github.com/dexidp/dex/connector" +) + +type conn struct { + Domain string + Host string + AdminUsername string + AdminPassword string + Logger logrus.FieldLogger +} + +type userKeystone struct { + Domain domainKeystone `json:"domain"` + ID string `json:"id"` + Name string `json:"name"` +} + +type domainKeystone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Config holds the configuration parameters for Keystone connector. +// Keystone should expose API v3 +// An example config: +// connectors: +// type: keystone +// id: keystone +// name: Keystone +// config: +// keystoneHost: http://example:5000 +// domain: default +// keystoneUsername: demo +// keystonePassword: DEMO_PASS +type Config struct { + Domain string `json:"domain"` + Host string `json:"keystoneHost"` + AdminUsername string `json:"keystoneUsername"` + AdminPassword string `json:"keystonePassword"` +} + +type loginRequestData struct { + auth `json:"auth"` +} + +type auth struct { + Identity identity `json:"identity"` +} + +type identity struct { + Methods []string `json:"methods"` + Password password `json:"password"` +} + +type password struct { + User user `json:"user"` +} + +type user struct { + Name string `json:"name"` + Domain domain `json:"domain"` + Password string `json:"password"` +} + +type domain struct { + ID string `json:"id"` +} + +type token struct { + User userKeystone `json:"user"` +} + +type tokenResponse struct { + Token token `json:"token"` +} + +type group struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type groupsResponse struct { + Groups []group `json:"groups"` +} + +var ( + _ connector.PasswordConnector = &conn{} + _ connector.RefreshConnector = &conn{} +) + +// Open returns an authentication strategy using Keystone. +func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector, error) { + return &conn{ + c.Domain, + c.Host, + c.AdminUsername, + c.AdminPassword, + logger}, nil +} + +func (p *conn) Close() error { return nil } + +func (p *conn) Login(ctx context.Context, scopes connector.Scopes, username, password string) (identity connector.Identity, validPassword bool, err error) { + resp, err := p.getTokenResponse(ctx, username, password) + if err != nil { + return identity, false, fmt.Errorf("keystone: error %v", err) + } + if resp.StatusCode/100 != 2 { + return identity, false, fmt.Errorf("keystone login: error %v", resp.StatusCode) + } + if resp.StatusCode != 201 { + return identity, false, nil + } + token := resp.Header.Get("X-Subject-Token") + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return identity, false, err + } + defer resp.Body.Close() + var tokenResp = new(tokenResponse) + err = json.Unmarshal(data, &tokenResp) + if err != nil { + return identity, false, fmt.Errorf("keystone: invalid token response: %v", err) + } + if scopes.Groups { + groups, err := p.getUserGroups(ctx, tokenResp.Token.User.ID, token) + if err != nil { + return identity, false, err + } + identity.Groups = groups + } + identity.Username = username + identity.UserID = tokenResp.Token.User.ID + return identity, true, nil +} + +func (p *conn) Prompt() string { return "username" } + +func (p *conn) Refresh( + ctx context.Context, scopes connector.Scopes, identity connector.Identity) (connector.Identity, error) { + + token, err := p.getAdminToken(ctx) + if err != nil { + return identity, fmt.Errorf("keystone: failed to obtain admin token: %v", err) + } + ok, err := p.checkIfUserExists(ctx, identity.UserID, token) + if err != nil { + return identity, err + } + if !ok { + return identity, fmt.Errorf("keystone: user %q does not exist", identity.UserID) + } + if scopes.Groups { + groups, err := p.getUserGroups(ctx, identity.UserID, token) + if err != nil { + return identity, err + } + identity.Groups = groups + } + return identity, nil +} + +func (p *conn) getTokenResponse(ctx context.Context, username, pass string) (response *http.Response, err error) { + client := &http.Client{} + jsonData := loginRequestData{ + auth: auth{ + Identity: identity{ + Methods: []string{"password"}, + Password: password{ + User: user{ + Name: username, + Domain: domain{ID: p.Domain}, + Password: pass, + }, + }, + }, + }, + } + jsonValue, err := json.Marshal(jsonData) + if err != nil { + return nil, err + } + // https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization + authTokenURL := p.Host + "/v3/auth/tokens/" + req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(jsonValue)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(ctx) + + return client.Do(req) +} + +func (p *conn) getAdminToken(ctx context.Context) (string, error) { + resp, err := p.getTokenResponse(ctx, p.AdminUsername, p.AdminPassword) + if err != nil { + return "", err + } + token := resp.Header.Get("X-Subject-Token") + return token, nil +} + +func (p *conn) checkIfUserExists(ctx context.Context, userID string, token string) (bool, error) { + // https://developer.openstack.org/api-ref/identity/v3/#show-user-details + userURL := p.Host + "/v3/users/" + userID + client := &http.Client{} + req, err := http.NewRequest("GET", userURL, nil) + if err != nil { + return false, err + } + + req.Header.Set("X-Auth-Token", token) + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return false, err + } + + if resp.StatusCode == 200 { + return true, nil + } + return false, err +} + +func (p *conn) getUserGroups(ctx context.Context, userID string, token string) ([]string, error) { + client := &http.Client{} + // https://developer.openstack.org/api-ref/identity/v3/#list-groups-to-which-a-user-belongs + groupsURL := p.Host + "/v3/users/" + userID + "/groups" + req, err := http.NewRequest("GET", groupsURL, nil) + req.Header.Set("X-Auth-Token", token) + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + p.Logger.Errorf("keystone: error while fetching user %q groups\n", userID) + return nil, err + } + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var groupsResp = new(groupsResponse) + + err = json.Unmarshal(data, &groupsResp) + if err != nil { + return nil, err + } + + groups := make([]string, len(groupsResp.Groups)) + for i, group := range groupsResp.Groups { + groups[i] = group.Name + } + return groups, nil +} diff --git a/connector/keystone/keystone_test.go b/connector/keystone/keystone_test.go new file mode 100644 index 00000000..d5d65ef1 --- /dev/null +++ b/connector/keystone/keystone_test.go @@ -0,0 +1,404 @@ +package keystone + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "reflect" + "strings" + "testing" + + "github.com/dexidp/dex/connector" +) + +const ( + invalidPass = "WRONG_PASS" + + testUser = "test_user" + testPass = "test_pass" + testEmail = "test@example.com" + testGroup = "test_group" + testDomain = "default" +) + +var ( + keystoneURL = "" + keystoneAdminURL = "" + adminUser = "" + adminPass = "" + authTokenURL = "" + usersURL = "" + groupsURL = "" +) + +type userResponse struct { + User struct { + ID string `json:"id"` + } `json:"user"` +} + +type groupResponse struct { + Group struct { + ID string `json:"id"` + } `json:"group"` +} + +func getAdminToken(t *testing.T, adminName, adminPass string) (token, id string) { + t.Helper() + client := &http.Client{} + + jsonData := loginRequestData{ + auth: auth{ + Identity: identity{ + Methods: []string{"password"}, + Password: password{ + User: user{ + Name: adminName, + Domain: domain{ID: testDomain}, + Password: adminPass, + }, + }, + }, + }, + } + + body, err := json.Marshal(jsonData) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(body)) + if err != nil { + t.Fatalf("keystone: failed to obtain admin token: %v\n", err) + } + + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + + token = resp.Header.Get("X-Subject-Token") + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + var tokenResp = new(tokenResponse) + err = json.Unmarshal(data, &tokenResp) + if err != nil { + t.Fatal(err) + } + return token, tokenResp.Token.User.ID +} + +func createUser(t *testing.T, token, userName, userEmail, userPass string) string { + t.Helper() + client := &http.Client{} + + createUserData := map[string]interface{}{ + "user": map[string]interface{}{ + "name": userName, + "email": userEmail, + "enabled": true, + "password": userPass, + "roles": []string{"admin"}, + }, + } + + body, err := json.Marshal(createUserData) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest("POST", usersURL, bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Auth-Token", token) + req.Header.Add("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + var userResp = new(userResponse) + err = json.Unmarshal(data, &userResp) + if err != nil { + t.Fatal(err) + } + + return userResp.User.ID +} + +// delete group or user +func delete(t *testing.T, token, id, uri string) { + t.Helper() + client := &http.Client{} + + deleteURI := uri + id + req, err := http.NewRequest("DELETE", deleteURI, nil) + if err != nil { + t.Fatalf("error: %v", err) + } + req.Header.Set("X-Auth-Token", token) + client.Do(req) +} + +func createGroup(t *testing.T, token, description, name string) string { + t.Helper() + client := &http.Client{} + + createGroupData := map[string]interface{}{ + "group": map[string]interface{}{ + "name": name, + "description": description, + }, + } + + body, err := json.Marshal(createGroupData) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest("POST", groupsURL, bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Auth-Token", token) + req.Header.Add("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + var groupResp = new(groupResponse) + err = json.Unmarshal(data, &groupResp) + if err != nil { + t.Fatal(err) + } + + return groupResp.Group.ID +} + +func addUserToGroup(t *testing.T, token, groupID, userID string) error { + t.Helper() + uri := groupsURL + groupID + "/users/" + userID + client := &http.Client{} + req, err := http.NewRequest("PUT", uri, nil) + if err != nil { + return err + } + req.Header.Set("X-Auth-Token", token) + client.Do(req) + return nil +} + +func TestIncorrectCredentialsLogin(t *testing.T) { + setupVariables(t) + c := conn{Host: keystoneURL, Domain: testDomain, + AdminUsername: adminUser, AdminPassword: adminPass} + s := connector.Scopes{OfflineAccess: true, Groups: true} + _, validPW, err := c.Login(context.Background(), s, adminUser, invalidPass) + + if validPW { + t.Fatal("Incorrect password check") + } + + if err == nil { + t.Fatal("Error should be returned when invalid password is provided") + } + + if !strings.Contains(err.Error(), "401") { + t.Fatal("Unrecognized error, expecting 401") + } +} + +func TestValidUserLogin(t *testing.T) { + setupVariables(t) + token, _ := getAdminToken(t, adminUser, adminPass) + userID := createUser(t, token, testUser, testEmail, testPass) + c := conn{Host: keystoneURL, Domain: testDomain, + AdminUsername: adminUser, AdminPassword: adminPass} + s := connector.Scopes{OfflineAccess: true, Groups: true} + identity, validPW, err := c.Login(context.Background(), s, testUser, testPass) + if err != nil { + t.Fatal(err.Error()) + } + t.Log(identity) + + if !validPW { + t.Fatal("Valid password was not accepted") + } + delete(t, token, userID, usersURL) +} + +func TestUseRefreshToken(t *testing.T) { + setupVariables(t) + token, adminID := getAdminToken(t, adminUser, adminPass) + groupID := createGroup(t, token, "Test group description", testGroup) + addUserToGroup(t, token, groupID, adminID) + + c := conn{Host: keystoneURL, Domain: testDomain, + AdminUsername: adminUser, AdminPassword: adminPass} + s := connector.Scopes{OfflineAccess: true, Groups: true} + + identityLogin, _, err := c.Login(context.Background(), s, adminUser, adminPass) + if err != nil { + t.Fatal(err.Error()) + } + + identityRefresh, err := c.Refresh(context.Background(), s, identityLogin) + if err != nil { + t.Fatal(err.Error()) + } + + delete(t, token, groupID, groupsURL) + + expectEquals(t, 1, len(identityRefresh.Groups)) + expectEquals(t, testGroup, string(identityRefresh.Groups[0])) +} + +func TestUseRefreshTokenUserDeleted(t *testing.T) { + setupVariables(t) + token, _ := getAdminToken(t, adminUser, adminPass) + userID := createUser(t, token, testUser, testEmail, testPass) + + c := conn{Host: keystoneURL, Domain: testDomain, + AdminUsername: adminUser, AdminPassword: adminPass} + s := connector.Scopes{OfflineAccess: true, Groups: true} + + identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass) + if err != nil { + t.Fatal(err.Error()) + } + + _, err = c.Refresh(context.Background(), s, identityLogin) + if err != nil { + t.Fatal(err.Error()) + } + + delete(t, token, userID, usersURL) + _, err = c.Refresh(context.Background(), s, identityLogin) + + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("unexpected error: %s", err.Error()) + } +} + +func TestUseRefreshTokenGroupsChanged(t *testing.T) { + setupVariables(t) + token, _ := getAdminToken(t, adminUser, adminPass) + userID := createUser(t, token, testUser, testEmail, testPass) + + c := conn{Host: keystoneURL, Domain: testDomain, + AdminUsername: adminUser, AdminPassword: adminPass} + s := connector.Scopes{OfflineAccess: true, Groups: true} + + identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass) + if err != nil { + t.Fatal(err.Error()) + } + + identityRefresh, err := c.Refresh(context.Background(), s, identityLogin) + if err != nil { + t.Fatal(err.Error()) + } + + expectEquals(t, 0, len(identityRefresh.Groups)) + + groupID := createGroup(t, token, "Test group", testGroup) + addUserToGroup(t, token, groupID, userID) + + identityRefresh, err = c.Refresh(context.Background(), s, identityLogin) + if err != nil { + t.Fatal(err.Error()) + } + + delete(t, token, groupID, groupsURL) + delete(t, token, userID, usersURL) + + expectEquals(t, 1, len(identityRefresh.Groups)) +} + +func TestNoGroupsInScope(t *testing.T) { + setupVariables(t) + token, _ := getAdminToken(t, adminUser, adminPass) + userID := createUser(t, token, testUser, testEmail, testPass) + + c := conn{Host: keystoneURL, Domain: testDomain, + AdminUsername: adminUser, AdminPassword: adminPass} + s := connector.Scopes{OfflineAccess: true, Groups: false} + + groupID := createGroup(t, token, "Test group", testGroup) + addUserToGroup(t, token, groupID, userID) + + identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass) + if err != nil { + t.Fatal(err.Error()) + } + expectEquals(t, 0, len(identityLogin.Groups)) + + identityRefresh, err := c.Refresh(context.Background(), s, identityLogin) + if err != nil { + t.Fatal(err.Error()) + } + expectEquals(t, 0, len(identityRefresh.Groups)) + + delete(t, token, groupID, groupsURL) + delete(t, token, userID, usersURL) +} + +func setupVariables(t *testing.T) { + keystoneURLEnv := "DEX_KEYSTONE_URL" + keystoneAdminURLEnv := "DEX_KEYSTONE_ADMIN_URL" + keystoneAdminUserEnv := "DEX_KEYSTONE_ADMIN_USER" + keystoneAdminPassEnv := "DEX_KEYSTONE_ADMIN_PASS" + keystoneURL = os.Getenv(keystoneURLEnv) + if keystoneURL == "" { + t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneURLEnv)) + return + } + keystoneAdminURL = os.Getenv(keystoneAdminURLEnv) + if keystoneAdminURL == "" { + t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneAdminURLEnv)) + return + } + adminUser = os.Getenv(keystoneAdminUserEnv) + if adminUser == "" { + t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneAdminUserEnv)) + return + } + adminPass = os.Getenv(keystoneAdminPassEnv) + if adminPass == "" { + t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneAdminPassEnv)) + return + } + authTokenURL = keystoneURL + "/v3/auth/tokens/" + usersURL = keystoneAdminURL + "/v3/users/" + groupsURL = keystoneAdminURL + "/v3/groups/" +} + +func expectEquals(t *testing.T, a interface{}, b interface{}) { + if !reflect.DeepEqual(a, b) { + t.Errorf("Expected %v to be equal %v", a, b) + } +} diff --git a/server/server.go b/server/server.go index cf9f7b47..ee3355b5 100644 --- a/server/server.go +++ b/server/server.go @@ -27,6 +27,7 @@ import ( "github.com/dexidp/dex/connector/bitbucketcloud" "github.com/dexidp/dex/connector/github" "github.com/dexidp/dex/connector/gitlab" + "github.com/dexidp/dex/connector/keystone" "github.com/dexidp/dex/connector/ldap" "github.com/dexidp/dex/connector/linkedin" "github.com/dexidp/dex/connector/microsoft" @@ -433,6 +434,7 @@ type ConnectorConfig interface { // ConnectorsConfig variable provides an easy way to return a config struct // depending on the connector type. var ConnectorsConfig = map[string]func() ConnectorConfig{ + "keystone": func() ConnectorConfig { return new(keystone.Config) }, "mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) }, "mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) }, "ldap": func() ConnectorConfig { return new(ldap.Config) },