Repository: dexidp/dex
Pull request #1515 (2019-08-09 15:50:37)

new connector for Atlassian Crowd
New connector for Atlassian Crowd identity management server. This initial implementation uses PasswordConnector ans should support groups and refresh. But there is no tests yet and sso feature is not tested with Jira or Confluence. Is this looks interesting?

nabokihms: @srenatus can you take a look at the PR again?

We run the Atlassian Crowd connector in production for a couple of months and everything seems fine!
For now, we:
* Fixed all things you mentioned in this PR
* Wrote the doc about Atlassian Crowd connector
* Added unit tests
* Added SVG icon and fixed CSS

If `@srenatus` is not available, maybe @bonifaido or @sagikazarmark can review this PR?

Thanks in advance!

Patch:
diff --git a/Documentation/connectors/atlassian-crowd.md b/Documentation/connectors/atlassian-crowd.md
new file mode 100644
index 00000000..3a5f0712
--- /dev/null
+++ b/Documentation/connectors/atlassian-crowd.md
@@ -0,0 +1,39 @@
+ Authentication through Atlassian Crowd
+
+## Overview
+
+Atlassian Crowd is a centralized identity management solution providing single sign-on and user identity.
+
+Current connector uses request to [Crowd REST API](https://developer.atlassian.com/server/crowd/json-requests-and-responses/) endpoints:
+* `/user` - to get user-info
+* `/session` - to authenticate the user
+
+Offline Access scope support provided with a new request to user authentication and user info endpoints. 
+
+## Configuration
+To start using the Atlassian Crowd connector, firstly you need to register an application in your Crowd like specified in the [docs](https://confluence.atlassian.com/crowd/adding-an-application-18579591.html).
+
+The following is an example of a configuration for dex `examples/config-dev.yaml`:
+
+```yaml
+connectors:
+- type: atlassian-crowd
+  # Required field for connector id.
+  id: crowd
+  # Required field for connector name.
+  name: Crowd
+  config:
+    # Required field to connect to Crowd.
+    baseURL: https://crowd.example.com/crowd
+    # Credentials can be string literals or pulled from the environment.
+    clientID: $ATLASSIAN_CROWD_APPLICATION_ID
+    clientSecret: $ATLASSIAN_CROWD_CLIENT_SECRET
+    # Optional groups whitelist, communicated through the "groups" scope.
+    # If `groups` is omitted, all of the user's Crowd groups are returned when the groups scope is present.
+    # If `groups` is provided, this acts as a whitelist - only the user's Crowd groups that are in the configured `groups` below will go into the groups claim.  
+    # Conversely, if the user is not in any of the configured `groups`, the user will not be authenticated.
+    groups:
+    - my-group
+    # Prompt for username field.
+    usernamePrompt: Login
+```
diff --git a/Documentation/connectors/ldap.md b/Documentation/connectors/ldap.md
index e69c3005..4bd540b5 100644
--- a/Documentation/connectors/ldap.md
+++ b/Documentation/connectors/ldap.md
@@ -123,12 +123,11 @@ connectors:
       # Optional filter to apply when searching the directory.
       filter: "(objectClass=group)"
 
-      # Following list contains field pairs that are used to match a user to a group. It adds an additional
+      # Following two fields are used to match a user to a group. It adds an additional
       # requirement to the filter that an attribute in the group must match the user's
       # attribute value.
-      userMatchers:
-      - userAttr: uid
-        groupAttr: member
+      userAttr: uid
+      groupAttr: member
 
       # Represents group name.
       nameAttr: name
@@ -216,9 +215,8 @@ groupSearch:
 
   # The group search needs to match the "uid" attribute on
   # the user with the "memberUid" attribute on the group.
-  userMatchers:
-  - userAttr: uid
-    groupAttr: memberUid
+  userAttr: uid
+  groupAttr: memberUid
 
   # Unique name of the group.
   nameAttr: cn
@@ -244,27 +242,8 @@ groupSearch:
   # Optional filter to apply when searching the directory.
   filter: "(objectClass=group)"
 
-  userMatchers:
-  - userAttr: DN # Use "DN" here not "uid"
-    groupAttr: member
-
-  nameAttr: name
-```
-
-There are cases when different types (objectClass) of groups use different attributes to keep a list of members. Below is an example of group query for such case:
-
-```yaml
-groupSearch:
-  baseDN: cn=groups,cn=compat,dc=example,dc=com
-  # Optional filter to search for different group types
-  filter: "(|(objectClass=posixGroup)(objectClass=group))"
-
-  # Use multiple user matchers so Dex will know which attribute names should be used to search for group members
-  userMatchers:
-  - userAttr: uid
-    groupAttr: memberUid
-  - userAttr: DN
-    groupAttr: member
+  userAttr: DN # Use "DN" here not "uid"
+  groupAttr: member
 
   nameAttr: name
 ```
@@ -296,9 +275,8 @@ connectors:
       # Would translate to the query "(&(objectClass=group)(member=<user uid>))".
       baseDN: cn=groups,dc=freeipa,dc=example,dc=com
       filter: "(objectClass=group)"
-      userMatchers:
-      - userAttr: uid
-        groupAttr: member
+      userAttr: uid
+      groupAttr: member
       nameAttr: name
 ```
 
@@ -337,9 +315,8 @@ connectors:
     groupSearch:
       baseDN: cn=Users,dc=example,dc=com
       filter: "(objectClass=group)"
-      userMatchers:
-      - userAttr: DN
-        groupAttr: member
+      userAttr: DN
+      groupAttr: member
       nameAttr: cn
 ```
 
diff --git a/Documentation/kubernetes.md b/Documentation/kubernetes.md
index f35fbd23..b7874c90 100644
--- a/Documentation/kubernetes.md
+++ b/Documentation/kubernetes.md
@@ -49,7 +49,7 @@ Additional notes:
 
 The dex repo contains scripts for running dex on a Kubernetes cluster with authentication through GitHub. The dex service is exposed using a [node port][node-port] on port 32000. This likely requires a custom `/etc/hosts` entry pointed at one of the cluster's workers.
 
-Because dex uses [CRDs](https://kubernetes.io/docs/tasks/access-kubernetes-api/custom-resources/custom-resource-definitions/) to store state, no external database is needed. For more details see the [storage documentation](storage.md#kubernetes-custom-resource-definitions-crds).
+Because dex uses [CRDs](https://kubernetes.io/docs/tasks/access-kubernetes-api/custom-resources/custom-resource-definitions/) to store state, no external database is needed. For more details see the [storage documentation](storage.md#kubernetes-third-party-resources).
 
 There are many different ways to spin up a Kubernetes development cluster, each with different host requirements and support for API server reconfiguration. At this time, this guide does not have copy-pastable examples, but can recommend the following methods for spinning up a cluster:
 
diff --git a/connector/atlassiancrowd/atlassiancrowd.go b/connector/atlassiancrowd/atlassiancrowd.go
new file mode 100644
index 00000000..d835d4cf
--- /dev/null
+++ b/connector/atlassiancrowd/atlassiancrowd.go
@@ -0,0 +1,437 @@
+// Package atlassiancrowd provides authentication strategies using Atlassian Crowd.
+package atlassiancrowd
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/dexidp/dex/connector"
+	"github.com/dexidp/dex/pkg/groups"
+	"github.com/dexidp/dex/pkg/log"
+)
+
+// Config holds configuration options for Atlassian Crowd connector.
+// Crowd connectors require executing two queries, the first to find
+// the user based on the username and password given to the connector.
+// The second to use the user entry to search for groups.
+//
+// An example config:
+//
+//     type: atlassian-crowd
+//     config:
+//       baseURL: https://crowd.example.com/context
+//       clientID: applogin
+//       clientSecret: appP4$$w0rd
+//       # users can be restricted by a list of groups
+//       groups:
+//       - admin
+//       # Prompt for username field
+//       usernamePrompt: Login
+//
+type Config struct {
+	BaseURL      string   `json:"baseURL"`
+	ClientID     string   `json:"clientID"`
+	ClientSecret string   `json:"clientSecret"`
+	Groups       []string `json:"groups"`
+
+	// UsernamePrompt allows users to override the username attribute (displayed
+	// in the username/password prompt). If unset, the handler will use.
+	// "Username".
+	UsernamePrompt string `json:"usernamePrompt"`
+}
+
+type crowdUser struct {
+	Key    string
+	Name   string
+	Active bool
+	Email  string
+}
+
+type crowdGroups struct {
+	Groups []struct {
+		Name string
+	} `json:"groups"`
+}
+
+type crowdAuthentication struct {
+	Token string
+	User  struct {
+		Name string
+	} `json:"user"`
+	CreatedDate uint64 `json:"created-date"`
+	ExpiryDate  uint64 `json:"expiry-date"`
+}
+
+type crowdAuthenticationError struct {
+	Reason  string
+	Message string
+}
+
+// Open returns a strategy for logging in through Atlassian Crowd
+func (c *Config) Open(_ string, logger log.Logger) (connector.Connector, error) {
+	if c.BaseURL == "" {
+		return nil, fmt.Errorf("crowd: no baseURL provided for crowd connector")
+	}
+	return &crowdConnector{Config: *c, logger: logger}, nil
+}
+
+type crowdConnector struct {
+	Config
+	logger log.Logger
+}
+
+var (
+	_ connector.PasswordConnector = (*crowdConnector)(nil)
+	_ connector.RefreshConnector  = (*crowdConnector)(nil)
+)
+
+type refreshData struct {
+	Username string `json:"username"`
+}
+
+func (c *crowdConnector) Login(ctx context.Context, s connector.Scopes, username, password string) (ident connector.Identity, validPass bool, err error) {
+	// make this check to avoid empty passwords.
+	if password == "" {
+		return connector.Identity{}, false, nil
+	}
+
+	// We want to return a different error if the user's password is incorrect vs
+	// if there was an error.
+	incorrectPass := false
+	var user crowdUser
+
+	client := c.crowdAPIClient()
+
+	if incorrectPass, err = c.authenticateWithPassword(ctx, client, username, password); err != nil {
+		return connector.Identity{}, false, err
+	}
+
+	if incorrectPass {
+		return connector.Identity{}, false, nil
+	}
+
+	if user, err = c.user(ctx, client, username); err != nil {
+		return connector.Identity{}, false, err
+	}
+
+	if ident, err = c.identityFromCrowdUser(user); err != nil {
+		return connector.Identity{}, false, err
+	}
+
+	if s.Groups {
+		userGroups, err := c.getGroups(ctx, client, s.Groups, ident.Username)
+		if err != nil {
+			return connector.Identity{}, false, fmt.Errorf("crowd: failed to query groups: %v", err)
+		}
+		ident.Groups = userGroups
+	}
+
+	if s.OfflineAccess {
+		refresh := refreshData{Username: username}
+		// Encode entry for following up requests such as the groups query and refresh attempts.
+		if ident.ConnectorData, err = json.Marshal(refresh); err != nil {
+			return connector.Identity{}, false, fmt.Errorf("crowd: marshal refresh data: %v", err)
+		}
+	}
+
+	return ident, true, nil
+}
+
+func (c *crowdConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
+	var data refreshData
+	if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
+		return ident, fmt.Errorf("crowd: failed to unmarshal internal data: %v", err)
+	}
+
+	var user crowdUser
+	client := c.crowdAPIClient()
+
+	user, err := c.user(ctx, client, data.Username)
+	if err != nil {
+		return ident, fmt.Errorf("crowd: get user %q: %v", data.Username, err)
+	}
+
+	newIdent, err := c.identityFromCrowdUser(user)
+	if err != nil {
+		return ident, err
+	}
+	newIdent.ConnectorData = ident.ConnectorData
+
+	// If user exists, authenticate it to prolong sso session.
+	err = c.authenticateUser(ctx, client, data.Username)
+	if err != nil {
+		return ident, fmt.Errorf("crowd: authenticate user: %v", err)
+	}
+
+	if s.Groups {
+		userGroups, err := c.getGroups(ctx, client, s.Groups, newIdent.Username)
+		if err != nil {
+			return connector.Identity{}, fmt.Errorf("crowd: failed to query groups: %v", err)
+		}
+		newIdent.Groups = userGroups
+	}
+	return newIdent, nil
+}
+
+func (c *crowdConnector) Prompt() string {
+	return c.UsernamePrompt
+}
+
+func (c *crowdConnector) crowdAPIClient() *http.Client {
+	return &http.Client{
+		Transport: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+			DialContext: (&net.Dialer{
+				Timeout:   30 * time.Second,
+				KeepAlive: 30 * time.Second,
+			}).DialContext,
+			MaxIdleConns:          100,
+			IdleConnTimeout:       90 * time.Second,
+			TLSHandshakeTimeout:   10 * time.Second,
+			ExpectContinueTimeout: 1 * time.Second,
+		},
+	}
+}
+
+// authenticateWithPassword creates a new session for user and validates a password with Crowd API
+func (c *crowdConnector) authenticateWithPassword(ctx context.Context, client *http.Client, username string, password string) (invalidPass bool, err error) {
+	req, err := c.crowdUserManagementRequest(ctx,
+		"POST",
+		"/session",
+		struct {
+			Username string `json:"username"`
+			Password string `json:"password"`
+		}{Username: username, Password: password},
+	)
+	if err != nil {
+		return false, fmt.Errorf("crowd: new auth pass api request %v", err)
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return false, fmt.Errorf("crowd: api request %v", err)
+	}
+	defer resp.Body.Close()
+
+	body, err := c.validateCrowdResponse(resp)
+	if err != nil {
+		return false, err
+	}
+
+	if resp.StatusCode != http.StatusCreated {
+		var authError crowdAuthenticationError
+		if err := json.Unmarshal(body, &authError); err != nil {
+			return false, fmt.Errorf("unmarshal auth pass response: %d %v %q", resp.StatusCode, err, string(body))
+		}
+
+		if authError.Reason == "INVALID_USER_AUTHENTICATION" {
+			return true, nil
+		}
+
+		return false, fmt.Errorf("%s: %s", resp.Status, authError.Message)
+	}
+
+	var authResponse crowdAuthentication
+
+	if err := json.Unmarshal(body, &authResponse); err != nil {
+		return false, fmt.Errorf("decode auth response: %v", err)
+	}
+
+	return false, nil
+}
+
+// authenticateUser creates a new session for user without password validations with Crowd API
+func (c *crowdConnector) authenticateUser(ctx context.Context, client *http.Client, username string) error {
+	req, err := c.crowdUserManagementRequest(ctx,
+		"POST",
+		"/session?validate-password=false",
+		struct {
+			Username string `json:"username"`
+		}{Username: username},
+	)
+	if err != nil {
+		return fmt.Errorf("crowd: new auth api request %v", err)
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return fmt.Errorf("crowd: api request %v", err)
+	}
+	defer resp.Body.Close()
+
+	body, err := c.validateCrowdResponse(resp)
+	if err != nil {
+		return err
+	}
+
+	if resp.StatusCode != http.StatusCreated {
+		return fmt.Errorf("%s: %s", resp.Status, body)
+	}
+
+	var authResponse crowdAuthentication
+
+	if err := json.Unmarshal(body, &authResponse); err != nil {
+		return fmt.Errorf("decode auth response: %v", err)
+	}
+
+	return nil
+}
+
+// user retrieves user info from Crowd API
+func (c *crowdConnector) user(ctx context.Context, client *http.Client, username string) (crowdUser, error) {
+	var user crowdUser
+
+	req, err := c.crowdUserManagementRequest(ctx,
+		"GET",
+		fmt.Sprintf("/user?username=%s", username),
+		nil,
+	)
+	if err != nil {
+		return user, fmt.Errorf("crowd: new user api request %v", err)
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return user, fmt.Errorf("crowd: api request %v", err)
+	}
+	defer resp.Body.Close()
+
+	body, err := c.validateCrowdResponse(resp)
+	if err != nil {
+		return user, err
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		return user, fmt.Errorf("%s: %s", resp.Status, body)
+	}
+
+	if err := json.Unmarshal(body, &user); err != nil {
+		return user, fmt.Errorf("failed to decode response: %v", err)
+	}
+
+	return user, nil
+}
+
+// groups retrieves groups from Crowd API
+func (c *crowdConnector) groups(ctx context.Context, client *http.Client, username string) (userGroups []string, err error) {
+	var crowdGroups crowdGroups
+
+	req, err := c.crowdUserManagementRequest(ctx,
+		"GET",
+		fmt.Sprintf("/user/group/nested?username=%s", username),
+		nil,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("crowd: new groups api request %v", err)
+	}
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("crowd: api request %v", err)
+	}
+	defer resp.Body.Close()
+
+	body, err := c.validateCrowdResponse(resp)
+	if err != nil {
+		return nil, err
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("%s: %s", resp.Status, body)
+	}
+
+	if err := json.Unmarshal(body, &crowdGroups); err != nil {
+		return nil, fmt.Errorf("failed to decode response: %v", err)
+	}
+
+	for _, group := range crowdGroups.Groups {
+		userGroups = append(userGroups, group.Name)
+	}
+
+	return userGroups, nil
+}
+
+// identityFromCrowdUser converts crowdUser to Identity
+func (c *crowdConnector) identityFromCrowdUser(user crowdUser) (connector.Identity, error) {
+	identity := connector.Identity{
+		Username:      user.Name,
+		UserID:        user.Key,
+		Email:         user.Email,
+		EmailVerified: true,
+	}
+
+	return identity, nil
+}
+
+// getGroups retrieves a list of user's groups and filters it
+func (c *crowdConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
+	crowdGroups, err := c.groups(ctx, client, userLogin)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(c.Groups) > 0 {
+		filteredGroups := groups.Filter(crowdGroups, c.Groups)
+		if len(filteredGroups) == 0 {
+			return nil, fmt.Errorf("crowd: user %q is not in any of the required groups", userLogin)
+		}
+		return filteredGroups, nil
+	} else if groupScope {
+		return crowdGroups, nil
+	}
+
+	return nil, nil
+}
+
+// crowdUserManagementRequest create a http.Request with basic auth, json payload and Accept header
+func (c *crowdConnector) crowdUserManagementRequest(ctx context.Context, method string, apiURL string, jsonPayload interface{}) (*http.Request, error) {
+	var body io.Reader
+	if jsonPayload != nil {
+		jsonData, err := json.Marshal(jsonPayload)
+		if err != nil {
+			return nil, fmt.Errorf("crowd: marshal API json payload: %v", err)
+		}
+		body = bytes.NewReader(jsonData)
+	}
+
+	req, err := http.NewRequest(method, fmt.Sprintf("%s/rest/usermanagement/1%s", c.BaseURL, apiURL), body)
+	if err != nil {
+		return nil, fmt.Errorf("new API req: %v", err)
+	}
+	req = req.WithContext(ctx)
+
+	// Crowd API requires a basic auth
+	req.SetBasicAuth(c.ClientID, c.ClientSecret)
+	req.Header.Set("Accept", "application/json")
+	if jsonPayload != nil {
+		req.Header.Set("Content-type", "application/json")
+	}
+	return req, nil
+}
+
+// validateCrowdResponse validates unique not JSON responses from API
+func (c *crowdConnector) validateCrowdResponse(resp *http.Response) ([]byte, error) {
+	body, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("crowd: read user body: %v", err)
+	}
+
+	if resp.StatusCode == http.StatusForbidden && strings.Contains(string(body), "The server understood the request but refuses to authorize it.") {
+		c.logger.Debugf("crowd response validation failed: %s", string(body))
+		return nil, fmt.Errorf("dex is forbidden from making requests to the Atlassian Crowd application by URL %q", c.BaseURL)
+	}
+
+	if resp.StatusCode == http.StatusUnauthorized && string(body) == "Application failed to authenticate" {
+		c.logger.Debugf("crowd response validation failed: %s", string(body))
+		return nil, fmt.Errorf("dex failed to authenticate Crowd Application with ID %q", c.ClientID)
+	}
+	return body, nil
+}
diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go
index 53f9062f..cac3539f 100644
--- a/connector/ldap/ldap.go
+++ b/connector/ldap/ldap.go
@@ -41,26 +41,16 @@ import (
 //         nameAttr: name
 //         preferredUsernameAttr: uid
 //       groupSearch:
-//         # Would translate to the separate query per user matcher pair and aggregate results into a single group list:
-//         #  "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(memberUid=<user uid>))"
-//         #  "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(member=<user DN>))"
+//         # Would translate to the query "(&(objectClass=group)(member=<user uid>))"
 //         baseDN: cn=groups,dc=example,dc=com
-//         filter: "(|(objectClass=posixGroup)(objectClass=groupOfNames))"
-//         userMatchers:
-//         - userAttr: uid
-//           groupAttr: memberUid
-//           # Use if full DN is needed and not available as any other attribute
-//           # Will only work if "DN" attribute does not exist in the record:
-//         - userAttr: DN
-//           groupAttr: member
+//         filter: "(objectClass=group)"
+//         userAttr: uid
+//         # Use if full DN is needed and not available as any other attribute
+//         # Will only work if "DN" attribute does not exist in the record
+//         # userAttr: DN
+//         groupAttr: member
 //         nameAttr: name
 //
-
-type UserMatcher struct {
-	UserAttr  string `json:"userAttr"`
-	GroupAttr string `json:"groupAttr"`
-}
-
 type Config struct {
 	// The host and optional port of the LDAP server. If port isn't supplied, it will be
 	// guessed based on the TLS configuration. 389 or 636.
@@ -134,22 +124,16 @@ type Config struct {
 
 		Scope string `json:"scope"` // Defaults to "sub"
 
-		// DEPRECATED config options. Those are left for backward compatibility.
-		// See "UserMatchers" below for the current group to user matching implementation
-		// TODO: should be eventually removed from the code
-		UserAttr  string `json:"userAttr"`
-		GroupAttr string `json:"groupAttr"`
-
-		// Array of the field pairs used to match a user to a group.
-		// See the "UserMatcher" struct for the exact field names
+		// These two fields are use to match a user to a group.
 		//
-		// Each pair adds an additional requirement to the filter that an attribute in the group
+		// It adds an additional requirement to the filter that an attribute in the group
 		// match the user's attribute value. For example that the "members" attribute of
 		// a group matches the "uid" of the user. The exact filter being added is:
 		//
-		//   (userMatchers[n].<groupAttr>=userMatchers[n].<userAttr value>)
+		//   (<groupAttr>=<userAttr value>)
 		//
-		UserMatchers []UserMatcher `json:"userMatchers"`
+		UserAttr  string `json:"userAttr"`
+		GroupAttr string `json:"groupAttr"`
 
 		// The attribute of the group that represents its name.
 		NameAttr string `json:"nameAttr"`
@@ -181,23 +165,6 @@ func parseScope(s string) (int, bool) {
 	return 0, false
 }
 
-// Build a list of group attr name to user attr value matchers.
-// Function exists here to allow backward compatibility between old and new
-// group to user matching implementations.
-// See "Config.GroupSearch.UserMatchers" comments for the details
-func (c *ldapConnector) userMatchers() []UserMatcher {
-	if len(c.GroupSearch.UserMatchers) > 0 && c.GroupSearch.UserMatchers[0].UserAttr != "" {
-		return c.GroupSearch.UserMatchers[:]
-	} else {
-		return []UserMatcher{
-			{
-				UserAttr:  c.GroupSearch.UserAttr,
-				GroupAttr: c.GroupSearch.GroupAttr,
-			},
-		}
-	}
-}
-
 // Open returns an authentication strategy using LDAP.
 func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
 	conn, err := c.OpenConnector(logger)
@@ -411,14 +378,11 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E
 		Attributes: []string{
 			c.UserSearch.IDAttr,
 			c.UserSearch.EmailAttr,
+			c.GroupSearch.UserAttr,
 			// TODO(ericchiang): what if this contains duplicate values?
 		},
 	}
 
-	for _, matcher := range c.userMatchers() {
-		req.Attributes = append(req.Attributes, matcher.UserAttr)
-	}
-
 	if c.UserSearch.NameAttr != "" {
 		req.Attributes = append(req.Attributes, c.UserSearch.NameAttr)
 	}
@@ -572,38 +536,36 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
 	}
 
 	var groups []*ldap.Entry
-	for _, matcher := range c.userMatchers() {
-		for _, attr := range getAttrs(user, matcher.UserAttr) {
-			filter := fmt.Sprintf("(%s=%s)", matcher.GroupAttr, ldap.EscapeFilter(attr))
-			if c.GroupSearch.Filter != "" {
-				filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter)
-			}
+	for _, attr := range getAttrs(user, c.GroupSearch.UserAttr) {
+		filter := fmt.Sprintf("(%s=%s)", c.GroupSearch.GroupAttr, ldap.EscapeFilter(attr))
+		if c.GroupSearch.Filter != "" {
+			filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter)
+		}
 
-			req := &ldap.SearchRequest{
-				BaseDN:     c.GroupSearch.BaseDN,
-				Filter:     filter,
-				Scope:      c.groupSearchScope,
-				Attributes: []string{c.GroupSearch.NameAttr},
-			}
+		req := &ldap.SearchRequest{
+			BaseDN:     c.GroupSearch.BaseDN,
+			Filter:     filter,
+			Scope:      c.groupSearchScope,
+			Attributes: []string{c.GroupSearch.NameAttr},
+		}
 
-			gotGroups := false
-			if err := c.do(ctx, func(conn *ldap.Conn) error {
-				c.logger.Infof("performing ldap search %s %s %s",
-					req.BaseDN, scopeString(req.Scope), req.Filter)
-				resp, err := conn.Search(req)
-				if err != nil {
-					return fmt.Errorf("ldap: search failed: %v", err)
-				}
-				gotGroups = len(resp.Entries) != 0
-				groups = append(groups, resp.Entries...)
-				return nil
-			}); err != nil {
-				return nil, err
-			}
-			if !gotGroups {
-				// TODO(ericchiang): Is this going to spam the logs?
-				c.logger.Errorf("ldap: groups search with filter %q returned no groups", filter)
+		gotGroups := false
+		if err := c.do(ctx, func(conn *ldap.Conn) error {
+			c.logger.Infof("performing ldap search %s %s %s",
+				req.BaseDN, scopeString(req.Scope), req.Filter)
+			resp, err := conn.Search(req)
+			if err != nil {
+				return fmt.Errorf("ldap: search failed: %v", err)
 			}
+			gotGroups = len(resp.Entries) != 0
+			groups = append(groups, resp.Entries...)
+			return nil
+		}); err != nil {
+			return nil, err
+		}
+		if !gotGroups {
+			// TODO(ericchiang): Is this going to spam the logs?
+			c.logger.Errorf("ldap: groups search with filter %q returned no groups", filter)
 		}
 	}
 
diff --git a/examples/config-ad-kubelogin.yaml b/examples/config-ad-kubelogin.yaml
index 559ccae9..20bb9bd8 100644
--- a/examples/config-ad-kubelogin.yaml
+++ b/examples/config-ad-kubelogin.yaml
@@ -41,11 +41,10 @@ connectors:
       baseDN: cn=Users,dc=example,dc=com
       filter: "(objectClass=group)"
 
-      userMatchers:
       # A user is a member of a group when their DN matches
       # the value of a "member" attribute on the group entity.
-      - userAttr: DN
-        groupAttr: member
+      userAttr: DN
+      groupAttr: member
 
       # The group name should be the "cn" value.
       nameAttr: cn
diff --git a/examples/config-ldap.yaml b/examples/config-ldap.yaml
index 05265b4b..21fb052c 100644
--- a/examples/config-ldap.yaml
+++ b/examples/config-ldap.yaml
@@ -37,11 +37,10 @@ connectors:
       baseDN: ou=Groups,dc=example,dc=org
       filter: "(objectClass=groupOfNames)"
 
-      userMatchers:
-        # A user is a member of a group when their DN matches
-        # the value of a "member" attribute on the group entity.
-      - userAttr: DN
-        groupAttr: member
+      # A user is a member of a group when their DN matches
+      # the value of a "member" attribute on the group entity.
+      userAttr: DN
+      groupAttr: member
 
       # The group name should be the "cn" value.
       nameAttr: cn
diff --git a/server/server.go b/server/server.go
index 21287b65..56354d01 100644
--- a/server/server.go
+++ b/server/server.go
@@ -21,6 +21,7 @@ import (
 	"golang.org/x/crypto/bcrypt"
 
 	"github.com/dexidp/dex/connector"
+	"github.com/dexidp/dex/connector/atlassiancrowd"
 	"github.com/dexidp/dex/connector/authproxy"
 	"github.com/dexidp/dex/connector/bitbucketcloud"
 	"github.com/dexidp/dex/connector/github"
@@ -471,6 +472,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{
 	"microsoft":       func() ConnectorConfig { return new(microsoft.Config) },
 	"bitbucket-cloud": func() ConnectorConfig { return new(bitbucketcloud.Config) },
 	"openshift":       func() ConnectorConfig { return new(openshift.Config) },
+	"atlassian-crowd": func() ConnectorConfig { return new(atlassiancrowd.Config) },
 	// Keep around for backwards compatibility.
 	"samlExperimental": func() ConnectorConfig { return new(saml.Config) },
 }
diff --git a/web/static/img/atlassian-crowd-icon.svg b/web/static/img/atlassian-crowd-icon.svg
new file mode 100644
index 00000000..cd94e300
--- /dev/null
+++ b/web/static/img/atlassian-crowd-icon.svg
@@ -0,0 +1,17 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="180.000000pt" height="180.000000pt" viewBox="0 0 180.000000 180.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,180.000000) scale(0.100000,-0.100000)"
+fill="#4169E1" stroke="none">
+<path d="M580 1422 l-315 -117 3 -214 c4 -298 25 -400 113 -548 73 -122 257
+-285 302 -267 11 4 157 326 157 347 0 3 -16 11 -35 17 -54 18 -122 92 -140
+152 -19 65 -19 93 0 149 56 165 256 222 386 110 77 -65 107 -169 74 -260 -22
+-64 -52 -99 -111 -132 -30 -17 -54 -37 -54 -45 0 -26 133 -336 147 -341 25
+-10 58 6 128 62 122 97 210 219 255 355 32 96 39 150 46 391 l6 219 -33 14
+c-48 20 -606 226 -610 226 -2 -1 -146 -54 -319 -118z"/>
+</g>
+</svg>
diff --git a/web/static/main.css b/web/static/main.css
index 2e6ce338..55b0e549 100644
--- a/web/static/main.css
+++ b/web/static/main.css
@@ -73,6 +73,11 @@ body {
   background-image: url(../static/img/bitbucket-icon.svg);
 }
 
+.dex-btn-icon--atlassian-crowd {
+  background-color: #CFDCEA;
+  background-image: url(../static/img/atlassian-crowd-icon.svg);
+}
+
 .dex-btn-icon--ldap {
   background-color: #84B6EF;
   background-image: url(../static/img/ldap-icon.svg);


Test patch:
diff --git a/connector/atlassiancrowd/atlassiancrowd_test.go b/connector/atlassiancrowd/atlassiancrowd_test.go
new file mode 100644
index 00000000..966f5b6f
--- /dev/null
+++ b/connector/atlassiancrowd/atlassiancrowd_test.go
@@ -0,0 +1,150 @@
+// Package atlassiancrowd provides authentication strategies using Atlassian Crowd.
+package atlassiancrowd
+
+import (
+	"context"
+	"crypto/tls"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"testing"
+
+	"github.com/sirupsen/logrus"
+)
+
+func TestUserGroups(t *testing.T) {
+	s := newTestServer(map[string]TestServerResponse{
+		"/rest/usermanagement/1/user/group/nested?username=testuser": {
+			Body: crowdGroups{Groups: []struct{ Name string }{{Name: "group1"}, {Name: "group2"}}},
+			Code: 200,
+		},
+	})
+	defer s.Close()
+
+	c := newTestCrowdConnector(s.URL)
+	groups, err := c.getGroups(context.Background(), newClient(), true, "testuser")
+
+	expectNil(t, err)
+	expectEquals(t, groups, []string{"group1", "group2"})
+}
+
+func TestUserGroupsWithFiltering(t *testing.T) {
+	s := newTestServer(map[string]TestServerResponse{
+		"/rest/usermanagement/1/user/group/nested?username=testuser": {
+			Body: crowdGroups{Groups: []struct{ Name string }{{Name: "group1"}, {Name: "group2"}}},
+			Code: 200,
+		},
+	})
+	defer s.Close()
+
+	c := newTestCrowdConnector(s.URL)
+	c.Groups = []string{"group1"}
+	groups, err := c.getGroups(context.Background(), newClient(), true, "testuser")
+
+	expectNil(t, err)
+	expectEquals(t, groups, []string{"group1"})
+}
+
+func TestUserLoginFlow(t *testing.T) {
+	s := newTestServer(map[string]TestServerResponse{
+		"/rest/usermanagement/1/session?validate-password=false": {
+			Body: crowdAuthentication{},
+			Code: 201,
+		},
+		"/rest/usermanagement/1/user?username=testuser": {
+			Body: crowdUser{Active: true, Name: "testuser", Email: "testuser@example.com"},
+			Code: 200,
+		},
+		"/rest/usermanagement/1/user?username=testuser2": {
+			Body: `<html>The server understood the request but refuses to authorize it.</html>`,
+			Code: 403,
+		},
+	})
+	defer s.Close()
+
+	c := newTestCrowdConnector(s.URL)
+	user, err := c.user(context.Background(), newClient(), "testuser")
+	expectNil(t, err)
+	expectEquals(t, user.Name, "testuser")
+	expectEquals(t, user.Email, "testuser@example.com")
+
+	_, err = c.identityFromCrowdUser(user)
+	expectNil(t, err)
+
+	err = c.authenticateUser(context.Background(), newClient(), "testuser")
+	expectNil(t, err)
+
+	_, err = c.user(context.Background(), newClient(), "testuser2")
+	expectEquals(t, err, fmt.Errorf("dex is forbidden from making requests to the Atlassian Crowd application by URL %q", s.URL))
+}
+
+func TestUserPassword(t *testing.T) {
+	s := newTestServer(map[string]TestServerResponse{
+		"/rest/usermanagement/1/session": {
+			Body: crowdAuthenticationError{Reason: "INVALID_USER_AUTHENTICATION", Message: "test"},
+			Code: 401,
+		},
+		"/rest/usermanagement/1/session?validate-password=false": {
+			Body: crowdAuthentication{},
+			Code: 201,
+		},
+	})
+	defer s.Close()
+
+	c := newTestCrowdConnector(s.URL)
+	invalidPassword, err := c.authenticateWithPassword(context.Background(), newClient(), "testuser", "testpassword")
+
+	expectNil(t, err)
+	expectEquals(t, invalidPassword, true)
+
+	err = c.authenticateUser(context.Background(), newClient(), "testuser")
+	expectNil(t, err)
+}
+
+type TestServerResponse struct {
+	Body interface{}
+	Code int
+}
+
+func newTestCrowdConnector(baseURL string) crowdConnector {
+	connector := crowdConnector{}
+	connector.BaseURL = baseURL
+	connector.logger = &logrus.Logger{
+		Out:       ioutil.Discard,
+		Level:     logrus.DebugLevel,
+		Formatter: &logrus.TextFormatter{DisableColors: true},
+	}
+	return connector
+}
+
+func newTestServer(responses map[string]TestServerResponse) *httptest.Server {
+	s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		response := responses[r.RequestURI]
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(response.Code)
+		json.NewEncoder(w).Encode(response.Body)
+	}))
+	return s
+}
+
+func newClient() *http.Client {
+	tr := &http.Transport{
+		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+	}
+	return &http.Client{Transport: tr}
+}
+
+func expectNil(t *testing.T, a interface{}) {
+	if a != nil {
+		t.Errorf("Expected %+v to equal nil", a)
+	}
+}
+
+func expectEquals(t *testing.T, a interface{}, b interface{}) {
+	if !reflect.DeepEqual(a, b) {
+		t.Errorf("Expected %+v to equal %+v", a, b)
+	}
+}
diff --git a/connector/ldap/ldap_test.go b/connector/ldap/ldap_test.go
index 1dc26afd..1ed2f878 100644
--- a/connector/ldap/ldap_test.go
+++ b/connector/ldap/ldap_test.go
@@ -307,12 +307,8 @@ member: cn=jane,ou=People,dc=example,dc=org
 	c.UserSearch.IDAttr = "DN"
 	c.UserSearch.Username = "cn"
 	c.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org"
-	c.GroupSearch.UserMatchers = []UserMatcher{
-		{
-			UserAttr:  "DN",
-			GroupAttr: "member",
-		},
-	}
+	c.GroupSearch.UserAttr = "DN"
+	c.GroupSearch.GroupAttr = "member"
 	c.GroupSearch.NameAttr = "cn"
 
 	tests := []subtest{
@@ -404,12 +400,8 @@ gidNumber: 1002
 	c.UserSearch.IDAttr = "DN"
 	c.UserSearch.Username = "cn"
 	c.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org"
-	c.GroupSearch.UserMatchers = []UserMatcher{
-		{
-			UserAttr:  "departmentNumber",
-			GroupAttr: "gidNumber",
-		},
-	}
+	c.GroupSearch.UserAttr = "departmentNumber"
+	c.GroupSearch.GroupAttr = "gidNumber"
 	c.GroupSearch.NameAttr = "cn"
 	tests := []subtest{
 		{
@@ -493,243 +485,6 @@ cn: admins
 member: cn=john,ou=People,dc=example,dc=org
 member: cn=jane,ou=People,dc=example,dc=org
 
-dn: cn=developers,ou=Groups,ou=Seattle,dc=example,dc=org
-objectClass: groupOfNames
-cn: developers
-member: cn=jane,ou=People,dc=example,dc=org
-`
-	c := &Config{}
-	c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
-	c.UserSearch.NameAttr = "cn"
-	c.UserSearch.EmailAttr = "mail"
-	c.UserSearch.IDAttr = "DN"
-	c.UserSearch.Username = "cn"
-	c.GroupSearch.BaseDN = "dc=example,dc=org"
-	c.GroupSearch.UserMatchers = []UserMatcher{
-		{
-			UserAttr:  "DN",
-			GroupAttr: "member",
-		},
-	}
-	c.GroupSearch.NameAttr = "cn"
-	c.GroupSearch.Filter = "(ou:dn:=Seattle)" // ignore other groups
-
-	tests := []subtest{
-		{
-			name:     "validpassword",
-			username: "jane",
-			password: "foo",
-			groups:   true,
-			want: connector.Identity{
-				UserID:        "cn=jane,ou=People,dc=example,dc=org",
-				Username:      "jane",
-				Email:         "janedoe@example.com",
-				EmailVerified: true,
-				Groups:        []string{"admins", "developers"},
-			},
-		},
-		{
-			name:     "validpassword2",
-			username: "john",
-			password: "bar",
-			groups:   true,
-			want: connector.Identity{
-				UserID:        "cn=john,ou=People,dc=example,dc=org",
-				Username:      "john",
-				Email:         "johndoe@example.com",
-				EmailVerified: true,
-				Groups:        []string{"admins"},
-			},
-		},
-	}
-
-	runTests(t, schema, connectLDAP, c, tests)
-}
-
-func TestGroupToUserMatchers(t *testing.T) {
-	schema := `
-dn: ou=People,dc=example,dc=org
-objectClass: organizationalUnit
-ou: People
-
-dn: cn=jane,ou=People,dc=example,dc=org
-objectClass: person
-objectClass: inetOrgPerson
-sn: doe
-cn: jane
-uid: janedoe
-mail: janedoe@example.com
-userpassword: foo
-
-dn: cn=john,ou=People,dc=example,dc=org
-objectClass: person
-objectClass: inetOrgPerson
-sn: doe
-cn: john
-uid: johndoe
-mail: johndoe@example.com
-userpassword: bar
-
-# Group definitions.
-
-dn: ou=Seattle,dc=example,dc=org
-objectClass: organizationalUnit
-ou: Seattle
-
-dn: ou=Portland,dc=example,dc=org
-objectClass: organizationalUnit
-ou: Portland
-
-dn: ou=Groups,ou=Seattle,dc=example,dc=org
-objectClass: organizationalUnit
-ou: Groups
-
-dn: ou=UnixGroups,ou=Seattle,dc=example,dc=org
-objectClass: organizationalUnit
-ou: UnixGroups
-
-dn: ou=Groups,ou=Portland,dc=example,dc=org
-objectClass: organizationalUnit
-ou: Groups
-
-dn: ou=UnixGroups,ou=Portland,dc=example,dc=org
-objectClass: organizationalUnit
-ou: UnixGroups
-
-dn: cn=qa,ou=Groups,ou=Portland,dc=example,dc=org
-objectClass: groupOfNames
-cn: qa
-member: cn=john,ou=People,dc=example,dc=org
-
-dn: cn=logger,ou=UnixGroups,ou=Portland,dc=example,dc=org
-objectClass: posixGroup
-gidNumber: 1000
-cn: logger
-memberUid: johndoe
-
-dn: cn=admins,ou=Groups,ou=Seattle,dc=example,dc=org
-objectClass: groupOfNames
-cn: admins
-member: cn=john,ou=People,dc=example,dc=org
-member: cn=jane,ou=People,dc=example,dc=org
-
-dn: cn=developers,ou=Groups,ou=Seattle,dc=example,dc=org
-objectClass: groupOfNames
-cn: developers
-member: cn=jane,ou=People,dc=example,dc=org
-
-dn: cn=frontend,ou=UnixGroups,ou=Seattle,dc=example,dc=org
-objectClass: posixGroup
-gidNumber: 1001
-cn: frontend
-memberUid: janedoe
-`
-	c := &Config{}
-	c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
-	c.UserSearch.NameAttr = "cn"
-	c.UserSearch.EmailAttr = "mail"
-	c.UserSearch.IDAttr = "DN"
-	c.UserSearch.Username = "cn"
-	c.GroupSearch.BaseDN = "dc=example,dc=org"
-	c.GroupSearch.UserMatchers = []UserMatcher{
-		{
-			UserAttr:  "DN",
-			GroupAttr: "member",
-		},
-		{
-			UserAttr:  "uid",
-			GroupAttr: "memberUid",
-		},
-	}
-	c.GroupSearch.NameAttr = "cn"
-	c.GroupSearch.Filter = "(|(objectClass=posixGroup)(objectClass=groupOfNames))" // search all group types
-
-	tests := []subtest{
-		{
-			name:     "validpassword",
-			username: "jane",
-			password: "foo",
-			groups:   true,
-			want: connector.Identity{
-				UserID:        "cn=jane,ou=People,dc=example,dc=org",
-				Username:      "jane",
-				Email:         "janedoe@example.com",
-				EmailVerified: true,
-				Groups:        []string{"admins", "developers", "frontend"},
-			},
-		},
-		{
-			name:     "validpassword2",
-			username: "john",
-			password: "bar",
-			groups:   true,
-			want: connector.Identity{
-				UserID:        "cn=john,ou=People,dc=example,dc=org",
-				Username:      "john",
-				Email:         "johndoe@example.com",
-				EmailVerified: true,
-				Groups:        []string{"qa", "admins", "logger"},
-			},
-		},
-	}
-
-	runTests(t, schema, connectLDAP, c, tests)
-}
-
-// Test deprecated group to user matching implementation
-// which was left for backward compatibility.
-// See "Config.GroupSearch.UserMatchers" comments for the details
-func TestDeprecatedGroupToUserMatcher(t *testing.T) {
-	schema := `
-dn: ou=People,dc=example,dc=org
-objectClass: organizationalUnit
-ou: People
-
-dn: cn=jane,ou=People,dc=example,dc=org
-objectClass: person
-objectClass: inetOrgPerson
-sn: doe
-cn: jane
-mail: janedoe@example.com
-userpassword: foo
-
-dn: cn=john,ou=People,dc=example,dc=org
-objectClass: person
-objectClass: inetOrgPerson
-sn: doe
-cn: john
-mail: johndoe@example.com
-userpassword: bar
-
-# Group definitions.
-
-dn: ou=Seattle,dc=example,dc=org
-objectClass: organizationalUnit
-ou: Seattle
-
-dn: ou=Portland,dc=example,dc=org
-objectClass: organizationalUnit
-ou: Portland
-
-dn: ou=Groups,ou=Seattle,dc=example,dc=org
-objectClass: organizationalUnit
-ou: Groups
-
-dn: ou=Groups,ou=Portland,dc=example,dc=org
-objectClass: organizationalUnit
-ou: Groups
-
-dn: cn=qa,ou=Groups,ou=Portland,dc=example,dc=org
-objectClass: groupOfNames
-cn: qa
-member: cn=john,ou=People,dc=example,dc=org
-
-dn: cn=admins,ou=Groups,ou=Seattle,dc=example,dc=org
-objectClass: groupOfNames
-cn: admins
-member: cn=john,ou=People,dc=example,dc=org
-member: cn=jane,ou=People,dc=example,dc=org
-
 dn: cn=developers,ou=Groups,ou=Seattle,dc=example,dc=org
 objectClass: groupOfNames
 cn: developers