diff --git a/README.md b/README.md index aa61e02b..b2756c47 100644 --- a/README.md +++ b/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: diff --git a/connector/dingtalk/dingtalk.go b/connector/dingtalk/dingtalk.go new file mode 100644 index 00000000..a9edb1fb --- /dev/null +++ b/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 +} diff --git a/connector/dingtalk/dingtalk_test.go b/connector/dingtalk/dingtalk_test.go new file mode 100644 index 00000000..89c343b1 --- /dev/null +++ b/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) + } +} diff --git a/server/server.go b/server/server.go index 0aac0a6c..d539d985 100755 --- a/server/server.go +++ b/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.