diff --git a/connector/keystone/keystone.go b/connector/keystone/keystone.go index 43a03a22..d62aa179 100644 --- a/connector/keystone/keystone.go +++ b/connector/keystone/keystone.go @@ -10,111 +10,155 @@ import ( "net/http" "bytes" "io/ioutil" - "log" ) -type KeystoneConnector struct { - domain string - keystoneURI string - Logger logrus.FieldLogger -} - var ( - _ connector.PasswordConnector = &KeystoneConnector{} + _ connector.PasswordConnector = &Connector{} + _ connector.RefreshConnector = &Connector{} ) -// Config holds the configuration parameters for Keystone connector. -// An example config: -// connectors: -// type: ksconfig -// id: keystone -// name: Keystone -// config: -// keystoneURI: http://example:5000/v3/auth/tokens -// domain: default - -type Config struct { - Domain string `json:"domain"` - KeystoneURI string `json:"keystoneURI"` -} - // Open returns an authentication strategy using Keystone. func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector, error) { - return &KeystoneConnector{c.Domain,c.KeystoneURI,logger}, nil + return &Connector{c.Domain, c.KeystoneHost, + c.KeystoneUsername, c.KeystonePassword, logger}, nil } -func (p KeystoneConnector) Close() error { return nil } +func (p Connector) Close() error { return nil } -// Declare KeystoneJson struct to get a token -type KeystoneJson struct { - Auth `json:"auth"` +func (p Connector) Login(ctx context.Context, s connector.Scopes, username, password string) ( + identity connector.Identity, validPassword bool, err error) { + response, err := p.getTokenResponse(username, password) + + // Providing wrong password or wrong keystone URI throws error + if err == nil && response.StatusCode == 201 { + token := response.Header["X-Subject-Token"][0] + data, _ := ioutil.ReadAll(response.Body) + + var tokenResponse = new(TokenResponse) + err := json.Unmarshal(data, &tokenResponse) + + if err != nil { + fmt.Printf("keystone: invalid token response: %v", err) + return identity, false, err + } + groups, err := p.getUserGroups(tokenResponse.Token.User.ID, token) + + if err != nil { + return identity, false, err + } + + identity.Username = username + identity.UserID = tokenResponse.Token.User.ID + identity.Groups = groups + return identity, true, nil + + } else if err != nil { + fmt.Printf("keystone: error %v", err) + return identity, false, err + + } else { + data, _ := ioutil.ReadAll(response.Body) + fmt.Println(string(data)) + return identity, false, err + } + return identity, false, nil } -type Auth struct { - Identity `json:"identity"` +func (p Connector) Prompt() string { return "username" } + +func (p Connector) Refresh( + ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) { + + if len(identity.ConnectorData) == 0 { + return identity, nil + } + + token, err := p.getAdminToken() + + if err != nil { + fmt.Printf("keystone: failed to obtain admin token") + return identity, err + } + + ok := p.checkIfUserExists(identity.UserID, token) + if !ok { + fmt.Printf("keystone: user %q does not exist\n", identity.UserID) + return identity, fmt.Errorf("keystone: user %q does not exist", identity.UserID) + } + + groups, err := p.getUserGroups(identity.UserID, token) + if err != nil { + fmt.Printf("keystone: Failed to fetch user %q groups", identity.UserID) + return identity, fmt.Errorf("keystone: failed to fetch user %q groups", identity.UserID) + } + + identity.Groups = groups + fmt.Printf("Identity data after use of refresh token: %v", identity) + return identity, nil } -type Identity struct { - Methods []string `json:"methods"` - Password `json:"password"` -} -type Password struct { - User `json:"user"` -} - -type User struct { - Name string `json:"name"` - Domain `json:"domain"` - Password string `json:"password"` -} - -type Domain struct { - ID string `json:"id"` -} - -func (p KeystoneConnector) Login(ctx context.Context, s connector.Scopes, username, password string) (identity connector.Identity, validPassword bool, err error) { - // Instantiate KeystoneJson struct type to get a token - jsonData := KeystoneJson{ +func (p Connector) getTokenResponse(username, password string) (response *http.Response, err error) { + jsonData := LoginRequestData{ Auth: Auth{ Identity: Identity{ Methods:[]string{"password"}, Password: Password{ User: User{ Name: username, - Domain: Domain{ID:p.domain}, + Domain: Domain{ID:p.Domain}, Password: password, }, }, }, }, } - - // Marshal jsonData jsonValue, _ := json.Marshal(jsonData) - - // Make an http post request to Keystone URI - response, err := http.Post(p.keystoneURI, "application/json", bytes.NewBuffer(jsonValue)) - - // Providing wrong password or wrong keystone URI throws error - if err == nil && response.StatusCode == 201 { - data, _ := ioutil.ReadAll(response.Body) - fmt.Println(string(data)) - identity.Username = username - return identity, true, nil - - } else if err != nil { - log.Fatal(err) - return identity, false, err - - } else { - fmt.Printf("The HTTP request failed with error %v\n", response.StatusCode) - data, _ := ioutil.ReadAll(response.Body) - fmt.Println(string(data)) - return identity, false, err - - } - return identity, false, nil + loginURI := p.KeystoneHost + "/v3/auth/tokens" + return http.Post(loginURI, "application/json", bytes.NewBuffer(jsonValue)) } -func (p KeystoneConnector) Prompt() string { return "username" } +func (p Connector) getAdminToken()(string, error) { + response, err := p.getTokenResponse(p.KeystoneUsername, p.KeystonePassword) + if err!= nil { + return "", err + } + token := response.Header["X-Subject-Token"][0] + return token, nil +} + +func (p Connector) checkIfUserExists(userID string, token string) (bool) { + groupsURI := p.KeystoneHost + "/v3/users/" + userID + client := &http.Client{} + req, _ := http.NewRequest("GET", groupsURI, nil) + req.Header.Set("X-Auth-Token", token) + response, err := client.Do(req) + if err == nil && response.StatusCode == 200 { + return true + } + return false +} + +func (p Connector) getUserGroups(userID string, token string) ([]string, error) { + groupsURI := p.KeystoneHost + "/v3/users/" + userID + "/groups" + client := &http.Client{} + req, _ := http.NewRequest("GET", groupsURI, nil) + req.Header.Set("X-Auth-Token", token) + response, err := client.Do(req) + + if err != nil { + fmt.Printf("keystone: error while fetching user %q groups\n", userID) + return nil, err + } + data, _ := ioutil.ReadAll(response.Body) + var groupsResponse = new(GroupsResponse) + err = json.Unmarshal(data, &groupsResponse) + if err != nil { + return nil, err + } + groups := []string{} + for _, group := range groupsResponse.Groups { + groups = append(groups, 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..b8bba0fe --- /dev/null +++ b/connector/keystone/keystone_test.go @@ -0,0 +1,275 @@ +package keystone + +import ( + "testing" + "github.com/dexidp/dex/connector" + + "fmt" + "io" + "os" + "time" + "net/http" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + networktypes "github.com/docker/docker/api/types/network" + "github.com/docker/go-connections/nat" + "golang.org/x/net/context" + "bytes" + "encoding/json" + "io/ioutil" +) + +const dockerCliVersion = "1.37" + +const exposedKeystonePort = "5000" +const exposedKeystonePortAdmin = "35357" + +const keystoneHost = "http://localhost" +const keystoneURL = keystoneHost + ":" + exposedKeystonePort +const keystoneAdminURL = keystoneHost + ":" + exposedKeystonePortAdmin +const authTokenURL = keystoneURL + "/v3/auth/tokens/" +const userURL = keystoneAdminURL + "/v3/users/" +const groupURL = keystoneAdminURL + "/v3/groups/" + +func startKeystoneContainer() string { + ctx := context.Background() + cli, err := client.NewClientWithOpts(client.WithVersion(dockerCliVersion)) + + if err != nil { + fmt.Printf("Error %v", err) + return "" + } + + imageName := "openio/openstack-keystone" + out, err := cli.ImagePull(ctx, imageName, types.ImagePullOptions{}) + if err != nil { + fmt.Printf("Error %v", err) + return "" + } + io.Copy(os.Stdout, out) + + resp, err := cli.ContainerCreate(ctx, &container.Config{ + Image: imageName, + }, &container.HostConfig{ + PortBindings: nat.PortMap{ + "5000/tcp": []nat.PortBinding{ + { + HostIP: "0.0.0.0", + HostPort: exposedKeystonePort, + }, + }, + "35357/tcp": []nat.PortBinding{ + { + HostIP: "0.0.0.0", + HostPort: exposedKeystonePortAdmin, + }, + }, + }, + }, &networktypes.NetworkingConfig{}, "dex_keystone_test") + + if err != nil { + fmt.Printf("Error %v", err) + return "" + } + + if err := cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil { + panic(err) + } + + fmt.Println(resp.ID) + return resp.ID +} + +func cleanKeystoneContainer(ID string) { + ctx := context.Background() + cli, err := client.NewClientWithOpts(client.WithVersion(dockerCliVersion)) + if err != nil { + fmt.Printf("Error %v", err) + return + } + duration := time.Duration(1) + if err:= cli.ContainerStop(ctx, ID, &duration); err != nil { + fmt.Printf("Error %v", err) + return + } + if err:= cli.ContainerRemove(ctx, ID, types.ContainerRemoveOptions{}); err != nil { + fmt.Printf("Error %v", err) + } +} + +func getAdminToken(admin_name, admin_pass string) (token string) { + client := &http.Client{} + + jsonData := LoginRequestData{ + Auth: Auth{ + Identity: Identity{ + Methods:[]string{"password"}, + Password: Password{ + User: User{ + Name: admin_name, + Domain: Domain{ID: "default"}, + Password: admin_pass, + }, + }, + }, + }, + } + + body, _ := json.Marshal(jsonData) + + req, _ := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(body)) + + req.Header.Set("Content-Type", "application/json") + resp, _ := client.Do(req) + + token = resp.Header["X-Subject-Token"][0] + return token +} + +func createUser(token, user_name, user_email, user_pass string) (string){ + client := &http.Client{} + + createUserData := CreateUserRequest{ + CreateUser: CreateUserForm{ + Name: user_name, + Email: user_email, + Enabled: true, + Password: user_pass, + Roles: []string{"admin"}, + }, + } + + body, _ := json.Marshal(createUserData) + + req, _ := http.NewRequest("POST", userURL, bytes.NewBuffer(body)) + req.Header.Set("X-Auth-Token", token) + req.Header.Add("Content-Type", "application/json") + resp, _ := client.Do(req) + + data, _ := ioutil.ReadAll(resp.Body) + var userResponse = new(UserResponse) + err := json.Unmarshal(data, &userResponse) + if err != nil { + fmt.Println(err) + } + + fmt.Println(userResponse.User.ID) + return userResponse.User.ID + +} + +func deleteUser(token, id string) { + client := &http.Client{} + + deleteUserURI := userURL + id + fmt.Println(deleteUserURI) + req, _ := http.NewRequest("DELETE", deleteUserURI, nil) + req.Header.Set("X-Auth-Token", token) + resp, _ := client.Do(req) + fmt.Println(resp) +} + +func createGroup(token, description, name string) string{ + client := &http.Client{} + + createGroupData := CreateGroup{ + CreateGroupForm{ + Description: description, + Name: name, + }, + } + + body, _ := json.Marshal(createGroupData) + + req, _ := http.NewRequest("POST", groupURL, bytes.NewBuffer(body)) + req.Header.Set("X-Auth-Token", token) + req.Header.Add("Content-Type", "application/json") + resp, _ := client.Do(req) + data, _ := ioutil.ReadAll(resp.Body) + + var groupResponse = new(GroupID) + err := json.Unmarshal(data, &groupResponse) + if err != nil { + fmt.Println(err) + } + + return groupResponse.Group.ID +} + +func addUserToGroup(token, groupId, userId string) { + uri := groupURL + groupId + "/users/" + userId + client := &http.Client{} + req, _ := http.NewRequest("PUT", uri, nil) + req.Header.Set("X-Auth-Token", token) + resp, _ := client.Do(req) + fmt.Println(resp) +} + +const adminUser = "demo" +const adminPass = "DEMO_PASS" +const invalidPass = "WRONG_PASS" + +const testUser = "test_user" +const testPass = "test_pass" +const testEmail = "test@example.com" + +const domain = "default" + +func TestIncorrectCredentialsLogin(t *testing.T) { + c := Connector{KeystoneHost: keystoneURL, Domain: domain, + KeystoneUsername: adminUser, KeystonePassword: adminPass} + s := connector.Scopes{OfflineAccess: true, Groups: true} + _, validPW, _ := c.Login(context.Background(), s, adminUser, invalidPass) + + if validPW { + t.Fail() + } +} + +func TestValidUserLogin(t *testing.T) { + token := getAdminToken(adminUser, adminPass) + userID := createUser(token, testUser, testEmail, testPass) + c := Connector{KeystoneHost: keystoneURL, Domain: domain, + KeystoneUsername: adminUser, KeystonePassword: adminPass} + s := connector.Scopes{OfflineAccess: true, Groups: true} + _, validPW, _ := c.Login(context.Background(), s, testUser, testPass) + if !validPW { + t.Fail() + } + deleteUser(token, userID) +} + +func TestUseRefreshToken(t *testing.T) { + t.Fatal("Not implemented") +} + +func TestUseRefreshTokenUserDeleted(t *testing.T){ + t.Fatal("Not implemented") +} + +func TestUseRefreshTokenGroupsChanged(t *testing.T){ + t.Fatal("Not implemented") +} + +func TestMain(m *testing.M) { + dockerID := startKeystoneContainer() + repeats := 10 + running := false + for i := 0; i < repeats; i++ { + _, err := http.Get(keystoneURL) + if err == nil { + running = true + break + } + time.Sleep(10 * time.Second) + } + if !running { + fmt.Printf("Failed to start keystone container") + os.Exit(1) + } + defer cleanKeystoneContainer(dockerID) + // run all tests + m.Run() +} diff --git a/connector/keystone/types.go b/connector/keystone/types.go new file mode 100644 index 00000000..9868a815 --- /dev/null +++ b/connector/keystone/types.go @@ -0,0 +1,136 @@ +package keystone + +import ( + "github.com/sirupsen/logrus" +) + +type Connector struct { + Domain string + KeystoneHost string + KeystoneUsername string + KeystonePassword string + Logger logrus.FieldLogger +} + +type ConnectorData struct { + AccessToken string `json:"accessToken"` +} + +type KeystoneUser struct { + Domain KeystoneDomain `json:"domain"` + ID string `json:"id"` + Name string `json:"name"` +} + +type KeystoneDomain struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Config struct { + Domain string `json:"domain"` + KeystoneHost string `json:"keystoneHost"` + KeystoneUsername string `json:"keystoneUsername"` + KeystonePassword string `json:"keystonePassword"` +} + +type LoginRequestData struct { + Auth `json:"auth"` +} + +type Auth struct { + Identity `json:"identity"` +} + +type Identity struct { + Methods []string `json:"methods"` + Password `json:"password"` +} + +type Password struct { + User `json:"user"` +} + +type User struct { + Name string `json:"name"` + Domain `json:"domain"` + Password string `json:"password"` +} + +type Domain struct { + ID string `json:"id"` +} + +type Token struct { + IssuedAt string `json:"issued_at"` + Extras map[string]interface{} `json:"extras"` + Methods []string `json:"methods"` + ExpiresAt string `json:"expires_at"` + User KeystoneUser `json:"user"` +} + +type TokenResponse struct { + Token Token `json:"token"` +} + +type CreateUserRequest struct { + CreateUser CreateUserForm `json:"user"` +} + +type CreateUserForm struct { + Name string `json:"name"` + Email string `json:"email"` + Enabled bool `json:"enabled"` + Password string `json:"password"` + Roles []string `json:"roles"` +} + +type UserResponse struct { + User CreateUserResponse `json:"user"` +} + +type CreateUserResponse struct { + Username string `json:"username"` + Name string `json:"name"` + Roles []string `json:"roles"` + Enabled bool `json:"enabled"` + Options string `json:"options"` + ID string `json:"id"` + Email string `json:"email"` +} + +type CreateGroup struct { + Group CreateGroupForm `json:"group"` +} + +type CreateGroupForm struct { + Description string `json:"description"` + Name string `json:"name"` +} + +type GroupID struct { + Group GroupIDForm `json:"group"` +} + +type GroupIDForm struct { + ID string `json:"id"` +} + +type Links struct { + Self string `json:"self"` + Previous string `json:"previous"` + Next string `json:"next"` +} + +type Group struct { + DomainID string `json:"domain_id` + Description string `json:"description"` + ID string `json:"id"` + Links Links `json:"links"` + Name string `json:"name"` +} + +type GroupsResponse struct { + Links Links `json:"links"` + Groups []Group `json:"groups"` +} diff --git a/examples/config-keystone.yaml b/examples/config-keystone.yaml index 22a00b08..9d5dfdf7 100644 --- a/examples/config-keystone.yaml +++ b/examples/config-keystone.yaml @@ -14,7 +14,11 @@ storage: # Configuration for the HTTP endpoints. web: - http: 0.0.0.0:5556 + https: 0.0.0.0:5556 + # Uncomment for HTTPS options. + # https: 127.0.0.1:5554 + tlsCert: ./ssl/dex.crt + tlsKey: ./ssl/dex.key # Configuration for telemetry telemetry: @@ -32,13 +36,20 @@ staticClients: secret: ZXhhbXBsZS1hcHAtc2VjcmV0 #Provide Keystone connector and its config here +# /v3/auth/tokens connectors: -- type: ksconfig +- type: keystone id: keystone name: Keystone config: - keystoneURI: http://example:5000/v3/auth/tokens + keystoneHost: http://localhost:5000 domain: default + keystoneUsername: demo + keystonePassword: DEMO_PASS # Let dex keep a list of passwords which can be used to login to dex. -enablePasswordDB: true \ No newline at end of file +enablePasswordDB: true + +oauth2: + skipApprovalScreen: true + diff --git a/server/handlers.go b/server/handlers.go index 5bdf39f0..100f5a38 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -211,6 +211,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { } authReqID := r.FormValue("req") + s.logger.Errorf("Auth req id %v", authReqID) authReq, err := s.storage.GetAuthRequest(authReqID) if err != nil { @@ -345,7 +346,7 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.") return } - s.logger.Errorf("Failed to get auth request: %v", err) + s.logger.Errorf("2Failed to get auth request: %v", err) s.renderError(w, http.StatusInternalServerError, "Database error.") return } @@ -357,6 +358,7 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) } conn, err := s.getConnector(authReq.ConnectorID) + s.logger.Errorf("X Connector %v", conn) if err != nil { s.logger.Errorf("Failed to get connector with id %q : %v", authReq.ConnectorID, err) s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.") @@ -435,7 +437,7 @@ func (s *Server) finalizeLogin(identity connector.Identity, authReq storage.Auth func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { authReq, err := s.storage.GetAuthRequest(r.FormValue("req")) if err != nil { - s.logger.Errorf("Failed to get auth request: %v", err) + s.logger.Errorf("3Failed to get auth request: %v", err) s.renderError(w, http.StatusInternalServerError, "Database error.") return } diff --git a/server/server.go b/server/server.go index 06869ebf..518200cd 100644 --- a/server/server.go +++ b/server/server.go @@ -434,7 +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{ - "ksconfig": func() ConnectorConfig { return new(keystone.Config) }, + "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) }, @@ -456,7 +456,7 @@ func openConnector(logger logrus.FieldLogger, conn storage.Connector) (connector f, ok := ConnectorsConfig[conn.Type] if !ok { - return c, fmt.Errorf("unknown connector type %q", conn.Type) + return c, fmt.Errorf("xunknown connector type %q", conn.Type) } connConfig := f()