Browse Source

feat(connector/dingtalk):Add Alibaba Dingtalk connector

connector: implement Alibaba dingtalk connector
connector/dingtalk implements authorization strategy via Alibaba Dingtalk's DingTalk OAuth 2.0 API.
DingTalk OAuth 2.0 API: https://open.dingtalk.com/document/orgapp/sso-overview.

Signed-off-by: xiaojin.hxj <xiaojin.hxj@alibaba-inc.com>
pull/2914/head
xiaojin.hxj 3 years ago
parent
commit
c90abfa1f1
  1. 1
      README.md
  2. 333
      connector/dingtalk/dingtalk.go
  3. 65
      connector/dingtalk/dingtalk_test.go
  4. 2
      server/server.go

1
README.md

@ -81,6 +81,7 @@ Dex implements the following connectors:
| [Atlassian Crowd](https://dexidp.io/docs/connectors/atlassiancrowd/) | yes | yes | yes * | beta | preferred_username claim must be configured through config |
| [Gitea](https://dexidp.io/docs/connectors/gitea/) | yes | no | yes | beta | |
| [OpenStack Keystone](https://dexidp.io/docs/connectors/keystone/) | yes | yes | no | alpha | |
| [Dingtalk](https://dexidp.io/docs/connectors/dingtalk/) | yes | no | yes | alpha | |
Stable, beta, and alpha are defined as:

333
connector/dingtalk/dingtalk.go

@ -0,0 +1,333 @@
// Package dingtalk provides authentication strategies using Dingtalk.
package dingtalk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"golang.org/x/oauth2"
"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/pkg/httpclient"
"github.com/dexidp/dex/pkg/log"
)
const (
// dingtalk scope support "openid" or "openid corpid"
// https://open.dingtalk.com/document/orgapp-server/obtain-identity-credentials
scopeOpenID = "openid"
)
// Config holds configuration options for dingtalk logins.
type Config struct {
BaseURL string `json:"baseURL"`
AppID string `json:"appID"`
AppSecret string `json:"appSecret"`
RedirectURI string `json:"redirectURI"`
Groups []string `json:"groups"`
UseLoginAsID bool `json:"useLoginAsID"`
}
type dingtalkUser struct {
Nick string `json:"nick"`
UnionID string `json:"unionId"`
OpenID string `json:"openId"`
Mobile string `json:"mobile"`
StateCode string `json:"stateCode"`
Email string `json:"email"`
}
// Open returns a strategy for logging in through Dingtalk.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
if c.BaseURL == "" {
c.BaseURL = "https://api.dingtalk.com"
}
httpClient, err := httpclient.NewHTTPClient(nil, false)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %v", err)
}
return &dingtalkConnector{
baseURL: c.BaseURL,
redirectURI: c.RedirectURI,
appID: c.AppID,
appSecret: c.AppSecret,
logger: logger,
groups: c.Groups,
useLoginAsID: c.UseLoginAsID,
httpClient: httpClient,
}, nil
}
type connectorData struct {
// Support Dingtalk's Access Tokens and Refresh tokens.
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
var (
_ connector.CallbackConnector = (*dingtalkConnector)(nil)
_ connector.RefreshConnector = (*dingtalkConnector)(nil)
)
type dingtalkConnector struct {
baseURL string
redirectURI string
groups []string
appID string
appSecret string
logger log.Logger
httpClient *http.Client
// if set to true will use the user's handle rather than their numeric id as the ID
useLoginAsID bool
}
func (c *dingtalkConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
dingtalkScopes := []string{scopeOpenID}
dingtalkEndpoint := oauth2.Endpoint{
AuthURL: "https://login.dingtalk.com/oauth2/auth",
TokenURL: c.baseURL + "/v1.0/oauth2/userAccessToken",
}
return &oauth2.Config{
ClientID: c.appID,
ClientSecret: c.appSecret,
Endpoint: dingtalkEndpoint,
Scopes: dingtalkScopes,
RedirectURL: c.redirectURI,
}
}
func (c *dingtalkConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
if c.redirectURI != callbackURL {
return "", fmt.Errorf("expected callback URL %s did not match the URL in the config %s", c.redirectURI, callbackURL)
}
var opts []oauth2.AuthCodeOption
if scopes.OfflineAccess {
opts = append(opts, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
}
return c.oauth2Config(scopes).AuthCodeURL(state, opts...), nil
}
type oauth2Error struct {
error string
errorDescription string
}
func (e *oauth2Error) Error() string {
if e.errorDescription == "" {
return e.error
}
return e.error + ": " + e.errorDescription
}
type UserAccessTokenReq struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
Code string `json:"code"`
RefreshToken string `json:"refreshToken"`
GrantType string `json:"grantType"`
}
type UserAccessTokenResp struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpireIn int `json:"expireIn"`
}
func (c *dingtalkConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
q := r.URL.Query()
if errType := q.Get("error"); errType != "" {
return identity, &oauth2Error{errType, q.Get("error_description")}
}
oauth2Config := c.oauth2Config(s)
var token oauth2.Token
ctx := r.Context()
reqBody := UserAccessTokenReq{
ClientID: oauth2Config.ClientID,
ClientSecret: oauth2Config.ClientSecret,
Code: q.Get("authCode"),
RefreshToken: q.Get("state"),
GrantType: "authorization_code",
}
body, _ := json.Marshal(reqBody)
resp, err := c.httpClient.Post(oauth2Config.Endpoint.TokenURL, "application/json", bytes.NewBuffer(body))
if err != nil {
c.logger.Errorf("in handlecallbak get token from [%s] reqBody is [%s] get err[%v]", oauth2Config.Endpoint.TokenURL, string(body), err)
return identity, fmt.Errorf("resp failed to get token: %v", err)
}
if resp.StatusCode == http.StatusOK {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return identity, fmt.Errorf("read response body get err: %v", err)
}
respToken := UserAccessTokenResp{}
json.Unmarshal(respBody, &respToken)
token = oauth2.Token{
AccessToken: respToken.AccessToken,
RefreshToken: respToken.RefreshToken,
Expiry: time.Now().Add(time.Second * time.Duration(respToken.ExpireIn)),
}
}
return c.identity(ctx, s, &token)
}
func (c *dingtalkConnector) identity(ctx context.Context, s connector.Scopes, token *oauth2.Token) (identity connector.Identity, err error) {
oauth2Config := c.oauth2Config(s)
// if c.httpClient != nil {
// ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient)
// }
client := oauth2Config.Client(ctx, token)
user, err := c.user(ctx, c.httpClient, token)
if err != nil {
c.logger.Errorf("in identity get user by ctx [%v] client is [%v] token is[%v]", ctx, client, token)
return identity, fmt.Errorf("dingtalk: get user: %v", err)
}
// mobile number is required rather than email address in Dingtalk
// if user do not have a email address, use mobile number instead.
email := user.Email
if email == "" {
email = user.Mobile
}
identity = connector.Identity{
UserID: user.UnionID,
Username: user.Nick,
PreferredUsername: user.Nick,
Email: email,
EmailVerified: true,
}
if c.useLoginAsID {
identity.UserID = user.Mobile
}
if c.groupsRequired(s.Groups) {
groups, err := c.getGroups(ctx, client, s.Groups, user.Mobile)
if err != nil {
return identity, fmt.Errorf("dingtalk: get groups: %v", err)
}
identity.Groups = groups
}
if s.OfflineAccess {
data := connectorData{RefreshToken: token.RefreshToken, AccessToken: token.AccessToken}
connData, err := json.Marshal(data)
if err != nil {
return identity, fmt.Errorf("dingtalk: marshal connector data: %v", err)
}
identity.ConnectorData = connData
}
return identity, nil
}
func (c *dingtalkConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) {
var data connectorData
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil {
return ident, fmt.Errorf("dingtalk: unmarshal connector data: %v", err)
}
oauth2Config := c.oauth2Config(s)
switch {
case data.RefreshToken != "":
{
var token oauth2.Token
reqBody := UserAccessTokenReq{
ClientID: oauth2Config.ClientID,
ClientSecret: oauth2Config.ClientSecret,
RefreshToken: data.RefreshToken,
GrantType: "refresh_token",
}
body, _ := json.Marshal(reqBody)
resp, err := http.Post(oauth2Config.Endpoint.TokenURL, "application/json", bytes.NewBuffer(body))
if err != nil {
return ident, fmt.Errorf("refresh token resp failed to get token: %v", err)
}
if resp.StatusCode == http.StatusOK {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return ident, fmt.Errorf("refresh token read response body get err: %v", err)
}
respToken := UserAccessTokenResp{}
json.Unmarshal(respBody, &respToken)
token = oauth2.Token{
AccessToken: respToken.AccessToken,
RefreshToken: respToken.RefreshToken,
Expiry: time.Now().Add(time.Second * time.Duration(respToken.ExpireIn)),
}
}
return c.identity(ctx, s, &token)
}
case data.AccessToken != "":
{
token := &oauth2.Token{
AccessToken: data.AccessToken,
}
return c.identity(ctx, s, token)
}
default:
return ident, errors.New("no refresh or access token found")
}
}
func (c *dingtalkConnector) groupsRequired(groupScope bool) bool {
return len(c.groups) > 0 || groupScope
}
// The HTTP native oauth client is constructed by the golang.org/x/oauth2 package, which inserts
// a bearer token as part of the request.
// Dingtalk use a x-acs-dingtalk-access-token header instead of bearer token.
// so we can't use oauth2.HTTPClient
func (c *dingtalkConnector) user(ctx context.Context, client *http.Client, token *oauth2.Token) (dingtalkUser, error) {
var u dingtalkUser
req, err := http.NewRequest("GET", c.baseURL+"/v1.0/contact/users/me", nil)
if err != nil {
return u, fmt.Errorf("dingtalk: new req: %v", err)
}
req.Header.Set("x-acs-dingtalk-access-token", token.AccessToken)
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
c.logger.Errorf("client.Do return err is [%v]", err)
return u, fmt.Errorf("dingtalk: get URL %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return u, fmt.Errorf("dingtalk: read body: %v", err)
}
return u, fmt.Errorf("%s: %s", resp.Status, body)
}
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return u, fmt.Errorf("failed to decode response: %v", err)
}
return u, nil
}
// TODO implement group feature by corpid
// corpid scope refer: https://open.dingtalk.com/document/orgapp/obtain-identity-credentials
func (c *dingtalkConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
return nil, nil
}

65
connector/dingtalk/dingtalk_test.go

@ -0,0 +1,65 @@
package dingtalk
import (
"crypto/tls"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
"github.com/dexidp/dex/connector"
)
// tests that the Mobile is used as their email when they have no email address
func TestUsernameIdentity(t *testing.T) {
s := newTestServer(map[string]interface{}{
"/v1.0/contact/users/me": dingtalkUser{UnionID: "1234", Email: "", Mobile: "138123xxxxx"},
"/v1.0/oauth2/userAccessToken": map[string]interface{}{
"accessToken": "xxxxxx",
"refreshToken": "xxxxxx",
"expiresIn": "30",
},
})
defer s.Close()
hostURL, err := url.Parse(s.URL)
expectNil(t, err)
req, err := http.NewRequest("GET", hostURL.String(), nil)
expectNil(t, err)
c := dingtalkConnector{baseURL: s.URL, httpClient: newClient()}
identity, err := c.HandleCallback(connector.Scopes{}, req)
expectNil(t, err)
expectEquals(t, identity.Email, "138123xxxxx")
}
func newTestServer(responses map[string]interface{}) *httptest.Server {
return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := responses[r.RequestURI]
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}))
}
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)
}
}

2
server/server.go

@ -29,6 +29,7 @@ import (
"github.com/dexidp/dex/connector/atlassiancrowd"
"github.com/dexidp/dex/connector/authproxy"
"github.com/dexidp/dex/connector/bitbucketcloud"
"github.com/dexidp/dex/connector/dingtalk"
"github.com/dexidp/dex/connector/gitea"
"github.com/dexidp/dex/connector/github"
"github.com/dexidp/dex/connector/gitlab"
@ -558,6 +559,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{
"atlassian-crowd": func() ConnectorConfig { return new(atlassiancrowd.Config) },
// Keep around for backwards compatibility.
"samlExperimental": func() ConnectorConfig { return new(saml.Config) },
"dingtalk": func() ConnectorConfig { return new(dingtalk.Config) },
}
// openConnector will parse the connector config and open the connector.

Loading…
Cancel
Save