Merge pull request #1515 from flant/atlassian-crowd-connector
new connector for Atlassian Crowd
This commit is contained in:
commit
b7cf701032
6 changed files with 650 additions and 0 deletions
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 a new issue