Merge pull request #1515 from flant/atlassian-crowd-connector
new connector for Atlassian Crowd
This commit is contained in:
		
							
								
								
									
										39
									
								
								Documentation/connectors/atlassian-crowd.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								Documentation/connectors/atlassian-crowd.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | ``` | ||||||
							
								
								
									
										437
									
								
								connector/atlassiancrowd/atlassiancrowd.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										437
									
								
								connector/atlassiancrowd/atlassiancrowd.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | } | ||||||
							
								
								
									
										150
									
								
								connector/atlassiancrowd/atlassiancrowd_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								connector/atlassiancrowd/atlassiancrowd_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @@ -21,6 +21,7 @@ import ( | |||||||
| 	"golang.org/x/crypto/bcrypt" | 	"golang.org/x/crypto/bcrypt" | ||||||
|  |  | ||||||
| 	"github.com/dexidp/dex/connector" | 	"github.com/dexidp/dex/connector" | ||||||
|  | 	"github.com/dexidp/dex/connector/atlassiancrowd" | ||||||
| 	"github.com/dexidp/dex/connector/authproxy" | 	"github.com/dexidp/dex/connector/authproxy" | ||||||
| 	"github.com/dexidp/dex/connector/bitbucketcloud" | 	"github.com/dexidp/dex/connector/bitbucketcloud" | ||||||
| 	"github.com/dexidp/dex/connector/github" | 	"github.com/dexidp/dex/connector/github" | ||||||
| @@ -477,6 +478,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ | |||||||
| 	"microsoft":       func() ConnectorConfig { return new(microsoft.Config) }, | 	"microsoft":       func() ConnectorConfig { return new(microsoft.Config) }, | ||||||
| 	"bitbucket-cloud": func() ConnectorConfig { return new(bitbucketcloud.Config) }, | 	"bitbucket-cloud": func() ConnectorConfig { return new(bitbucketcloud.Config) }, | ||||||
| 	"openshift":       func() ConnectorConfig { return new(openshift.Config) }, | 	"openshift":       func() ConnectorConfig { return new(openshift.Config) }, | ||||||
|  | 	"atlassian-crowd": func() ConnectorConfig { return new(atlassiancrowd.Config) }, | ||||||
| 	// Keep around for backwards compatibility. | 	// Keep around for backwards compatibility. | ||||||
| 	"samlExperimental": func() ConnectorConfig { return new(saml.Config) }, | 	"samlExperimental": func() ConnectorConfig { return new(saml.Config) }, | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								web/static/img/atlassian-crowd-icon.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								web/static/img/atlassian-crowd-icon.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
| After Width: | Height: | Size: 861 B | 
| @@ -73,6 +73,11 @@ body { | |||||||
|   background-image: url(../static/img/bitbucket-icon.svg); |   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 { | .dex-btn-icon--ldap { | ||||||
|   background-color: #84B6EF; |   background-color: #84B6EF; | ||||||
|   background-image: url(../static/img/ldap-icon.svg); |   background-image: url(../static/img/ldap-icon.svg); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user