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/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/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: `The server understood the request but refuses to authorize it.`, + 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/server/server.go b/server/server.go index 49d177cd..09292b16 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" @@ -477,6 +478,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 @@ + + + + + + + + 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);