diff --git a/Gopkg.lock b/Gopkg.lock index 5a3758a17..1294a9050 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -131,7 +131,7 @@ version = "v1.0.0" [[projects]] - name = "gopkg.in/fsnotify.v1" + name = "gopkg.in/fsnotify/fsnotify.v1" packages = ["."] revision = "836bfd95fecc0f1511dd66bdbf2b5b61ab8b00b6" version = "v1.2.11" @@ -149,6 +149,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "b502c41a61115d14d6379be26b0300f65d173bdad852f0170d387ebf2d7ec173" + inputs-digest = "cfdd05348394cd0597edb858bdba5681665358a963356ed248d98f39373c33b5" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index c4005e114..422bd43b6 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -4,7 +4,7 @@ # [[constraint]] - name = "github.com/18F/hmacauth" + name = "github.com/mbland/hmacauth" version = "~1.0.1" [[constraint]] @@ -36,7 +36,7 @@ name = "google.golang.org/api" [[constraint]] - name = "gopkg.in/fsnotify.v1" + name = "gopkg.in/fsnotify/fsnotify.v1" version = "~1.2.0" [[constraint]] diff --git a/README.md b/README.md index 03c753cce..36e0abb28 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Valid providers are : * [GitHub](#github-auth-provider) * [GitLab](#gitlab-auth-provider) * [LinkedIn](#linkedin-auth-provider) +* [DingTalk](#dingtalk-auth-provider) The provider can be selected using the `provider` configuration value. @@ -155,6 +156,20 @@ OpenID Connect is a spec for OAUTH 2.0 + identity that is implemented by many ma -cookie-secure=false -email-domain example.com +### DingTalk Auth Provider + +For DingTalk, the registration steps are: + +1. Create a new qrcode login application: https://open-dev.dingtalk.com/#/loginAndShareApp +2. Get corpid and corpsecret of your organization: https://open-dev.dingtalk.com/#/corpAuthInfo +3. Run the oauth2_proxy with the following args: + + -provider dingtalk + -client-id oauth2_proxy + -client-secret proxy + -dingtalk-corpid corpid + -dingtalk-corpsecret corpsecret + ## Email Authentication To authorize by email domain use `--email-domain=yourcompany.com`. To authorize individual email addresses use `--authenticated-emails-file=/path/to/file` with one email per line. To authorize all email addresses use `--email-domain=*`. diff --git a/contrib/oauth2_proxy.cfg.example b/contrib/oauth2_proxy.cfg.example index 4ea2482d1..e1d53d98b 100644 --- a/contrib/oauth2_proxy.cfg.example +++ b/contrib/oauth2_proxy.cfg.example @@ -78,3 +78,9 @@ # cookie_refresh = "" # cookie_secure = true # cookie_httponly = true + +# Additional conf required by DigntTalk provider +# dingtalk_corpid = "ding07511073f402a4e035c2f4657eb2321f" +# dingtalk_corpsecret = "" +# fully qualified name of department separated by '/' if you need limit to be logged in by given departments +# dingtalk_departments = [ "xx公司/yy部门/zz组" ] \ No newline at end of file diff --git a/main.go b/main.go index 287dc4894..5a6c3d13b 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ func main() { upstreams := StringArray{} skipAuthRegex := StringArray{} googleGroups := StringArray{} + dingTalkDepartments := StringArray{} config := flagSet.String("config", "", "path to config file") showVersion := flagSet.Bool("version", false, "print version string") @@ -46,6 +47,9 @@ func main() { flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.") flagSet.String("github-org", "", "restrict logins to members of this organisation") flagSet.String("github-team", "", "restrict logins to members of this team") + flagSet.Var(&dingTalkDepartments, "dingtalk-departments", "restrict logins to members of this department(may be given multiple times).") + flagSet.String("dingtalk-corpid", "", "corpid of corp in dingtalk") + flagSet.String("dingtalk-corpsecret", "", "corp secret of corp in dingtalk") flagSet.Var(&googleGroups, "google-group", "restrict logins to members of this google group (may be given multiple times).") flagSet.String("google-admin-email", "", "the google admin to impersonate for api calls") flagSet.String("google-service-account-json", "", "the path to the service account json credentials") diff --git a/options.go b/options.go index 949fbba80..70186a854 100644 --- a/options.go +++ b/options.go @@ -34,6 +34,9 @@ type Options struct { EmailDomains []string `flag:"email-domain" cfg:"email_domains"` GitHubOrg string `flag:"github-org" cfg:"github_org"` GitHubTeam string `flag:"github-team" cfg:"github_team"` + DingTalkDepartments []string `flag:"dingtalk-departments" cfg:"dingtalk_departments"` + DingTalkCorpID string `flag:"dingtalk-corpid" cfg:"dingtalk_corpid" env:"OAUTH2_PROXY_DINGTALK_CORPID"` + DingTalkCorpSecret string `flag:"dingtalk-corpsecret" cfg:"dingtalk_corpsecret" env:"OAUTH2_PROXY_DINGTALK_CORPSECRET"` GoogleGroups []string `flag:"google-group" cfg:"google_group"` GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email"` GoogleServiceAccountJSON string `flag:"google-service-account-json" cfg:"google_service_account_json"` @@ -278,6 +281,11 @@ func parseProviderInfo(o *Options, msgs []string) []string { } else { p.Verifier = o.oidcVerifier } + case *providers.DingTalkProvider: + err := p.SetCorpInfoAndDepartments(o.DingTalkCorpID, o.DingTalkCorpSecret, o.DingTalkDepartments) + if err != nil { + msgs = append(msgs, "invalid DingTalk corp configuration: "+err.Error()) + } } return msgs } diff --git a/providers/dingtalk.go b/providers/dingtalk.go new file mode 100644 index 000000000..61b0d72cc --- /dev/null +++ b/providers/dingtalk.go @@ -0,0 +1,477 @@ +package providers + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "sort" + "time" + + "github.com/bitly/oauth2_proxy/api" +) + +type DingTalkProvider struct { + *ProviderData + Departments map[int64]string + CorpAccessToken struct { + CorpID string + CorpSSOSecret string + AccessToken string + ExpiresOn time.Time + } +} + +func NewDingTalkProvider(p *ProviderData) *DingTalkProvider { + p.ProviderName = "DingTalk" + if p.LoginURL == nil || p.LoginURL.String() == "" { + p.LoginURL = &url.URL{ + Scheme: "https", + Host: "oapi.dingtalk.com", + Path: "/connect/qrconnect", + } + } + if p.RedeemURL == nil || p.RedeemURL.String() == "" { + p.RedeemURL = &url.URL{ + Scheme: "https", + Host: "oapi.dingtalk.com", + Path: "/login/oauth/access_token", + } + } + // ValidationURL is the API Base URL + if p.ValidateURL == nil || p.ValidateURL.String() == "" { + p.ValidateURL = &url.URL{ + Scheme: "https", + Host: "oapi.dingtalk.com", + Path: "/", + } + } + if p.Scope == "" { + p.Scope = "snsapi_login" + } + return &DingTalkProvider{ProviderData: p} +} + +func (p *DingTalkProvider) GetLoginURL(redirectURI, state string) string { + var a url.URL + a = *p.LoginURL + params, _ := url.ParseQuery(a.RawQuery) + params.Set("redirect_uri", redirectURI) + params.Add("scope", p.Scope) + params.Set("appid", p.ClientID) + params.Set("response_type", "code") + params.Add("state", state) + a.RawQuery = params.Encode() + return a.String() +} + +/** +Get access token of app via client id and client secret +https://open-doc.dingtalk.com/microapp/serverapi2/athq8o#%E8%8E%B7%E5%8F%96%E9%92%89%E9%92%89%E5%BC%80%E6%94%BE%E5%BA%94%E7%94%A8%E7%9A%84access_token +**/ +func (p *DingTalkProvider) GetAppAccessToken() (accessToken string, err error) { + params := url.Values{ + "appid": {p.ClientID}, + "appsecret": {p.ClientSecret}, + } + gettokenURL := &url.URL{ + Scheme: p.RedeemURL.Scheme, + Host: p.RedeemURL.Host, + Path: "/sns/gettoken", + RawQuery: params.Encode(), + } + + var req *http.Request + req, err = http.NewRequest("GET", gettokenURL.String(), nil) + if err != nil { + return + } + + var resp *http.Response + resp, err = http.DefaultClient.Do(req) + if err != nil { + return "", err + } + var body []byte + body, err = ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return + } + + if resp.StatusCode != 200 { + err = fmt.Errorf("got %d from %q %s", resp.StatusCode, gettokenURL.String(), body) + return + } + + // blindly try json and x-www-form-urlencoded + var jsonResponse struct { + AccessToken string `json:"access_token"` + ErrorCode int `json:"errcode"` + ErrMessage string `json:"errmsg"` + } + err = json.Unmarshal(body, &jsonResponse) + if err == nil { + if 0 == jsonResponse.ErrorCode { + accessToken = jsonResponse.AccessToken + return + } + err = fmt.Errorf("got error code %d from %q %s", jsonResponse.ErrorCode, gettokenURL.String(), jsonResponse.ErrMessage) + return + } + + err = fmt.Errorf("no access token of app found %s", body) + return +} + +/** +Get persistent code then sns token as session state +https://open-doc.dingtalk.com/microapp/serverapi2/athq8o#%E8%8E%B7%E5%8F%96%E7%94%A8%E6%88%B7%E6%8E%88%E6%9D%83%E7%9A%84%E6%8C%81%E4%B9%85%E6%8E%88%E6%9D%83%E7%A0%81 +*/ +func (p *DingTalkProvider) GetSNSToken(accessToken string, code string) (s *SessionState, err error) { + params := url.Values{} + params.Add("access_token", accessToken) + getPersistentCodeURL := &url.URL{ + Scheme: p.RedeemURL.Scheme, + Host: p.RedeemURL.Host, + Path: "/sns/get_persistent_code", + RawQuery: params.Encode(), + } + body := map[string]string{"tmp_auth_code": code} + var req *http.Request + jsonBody, err := json.Marshal(body) + req, err = http.NewRequest("POST", getPersistentCodeURL.String(), bytes.NewBuffer(jsonBody)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + var persistentCodeResp struct { + PersistentCode string `json:"persistent_code"` + Openid string `json:"openid"` + Unionid string `json:"unionid"` + ErrorCode int `json:"errcode"` + ErrMessage string `json:"errmsg"` + } + err = api.RequestJson(req, &persistentCodeResp) + if err != nil { + return nil, err + } + if persistentCodeResp.ErrorCode != 0 { + err = fmt.Errorf("got error code %d from %q %s", persistentCodeResp.ErrorCode, getPersistentCodeURL.String(), persistentCodeResp.ErrMessage) + return + } + + getSNSTokenURL := &url.URL{ + Scheme: p.RedeemURL.Scheme, + Host: p.RedeemURL.Host, + Path: "/sns/get_sns_token", + RawQuery: params.Encode(), + } + body = map[string]string{"openid": persistentCodeResp.Openid, + "persistent_code": persistentCodeResp.PersistentCode} + jsonBody, err = json.Marshal(body) + req, err = http.NewRequest("POST", getSNSTokenURL.String(), bytes.NewBuffer(jsonBody)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + var snsTokenResp struct { + SNSToken string `json:"sns_token"` + ExpiresIn int `json:"expires_in"` + ErrorCode int `json:"errcode"` + ErrMessage string `json:"errmsg"` + } + err = api.RequestJson(req, &snsTokenResp) + if err != nil { + return nil, err + } + if snsTokenResp.ErrorCode != 0 { + err = fmt.Errorf("got error code %d from %q %s", snsTokenResp.ErrorCode, getSNSTokenURL.String(), snsTokenResp.ErrMessage) + return + } + s = &SessionState{ + AccessToken: snsTokenResp.SNSToken, + ExpiresOn: time.Now().Add(time.Duration(snsTokenResp.ExpiresIn-60) * time.Second).Truncate(time.Second), + } + + return +} + +func (p *DingTalkProvider) Redeem(redirectURL, code string) (s *SessionState, err error) { + if code == "" { + err = errors.New("missing code") + return + } + + accessToken, err := p.GetAppAccessToken() + if err != nil { + return + } + + s, err = p.GetSNSToken(accessToken, code) + return +} + +func (p *DingTalkProvider) SetCorpInfoAndDepartments(corpID, corpSSOSecret string, departments []string) (err error) { + p.CorpAccessToken.CorpID = corpID + p.CorpAccessToken.CorpSSOSecret = corpSSOSecret + err = p.RefreshCorpAccessToken() + if err != nil { + return err + } + departmentMap := make(map[string]string) + for _, department := range departments { + departmentMap[department] = "" + } + return p.InitialDepartments(departmentMap) +} + +func (p *DingTalkProvider) InitialDepartments(departments map[string]string) (err error) { + if len(departments) > 0 { + params := url.Values{} + params.Add("access_token", p.CorpAccessToken.AccessToken) + params.Add("fetch_child", "true") + getDepartmentsURL := &url.URL{ + Scheme: p.RedeemURL.Scheme, + Host: p.RedeemURL.Host, + Path: "/department/list", + RawQuery: params.Encode(), + } + var req *http.Request + req, err = http.NewRequest("GET", getDepartmentsURL.String(), nil) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + var departmentListResp struct { + ErrorCode int `json:"errcode"` + ErrMessage string `json:"errmsg"` + Departments []struct { + ID int64 `json:"id"` + Name string `json:"name"` + ParentID int64 `json:"parentid"` + } `json:"department"` + } + err = api.RequestJson(req, &departmentListResp) + if err != nil { + return + } + if departmentListResp.ErrorCode != 0 { + err = fmt.Errorf("got error code %d from %q %s", departmentListResp.ErrorCode, getDepartmentsURL.String(), departmentListResp.ErrMessage) + return + } + departmentsInfo := make(map[int64]string) + p.Departments = make(map[int64]string) + sort.Slice(departmentListResp.Departments, func(i, j int) bool { + if departmentListResp.Departments[i].ID == 1 { + return true + } + if departmentListResp.Departments[j].ID == 1 { + return false + } + if departmentListResp.Departments[i].ParentID == 1 { + return true + } + if departmentListResp.Departments[i].ParentID == departmentListResp.Departments[j].ID { + return false + } + if departmentListResp.Departments[j].ParentID == departmentListResp.Departments[i].ID { + return true + } + return departmentListResp.Departments[i].ID < departmentListResp.Departments[j].ID + }) + for _, department := range departmentListResp.Departments { + if department.ID == 1 { + departmentsInfo[department.ID] = department.Name + } else { + departmentsInfo[department.ID] = departmentsInfo[department.ParentID] + "/" + department.Name + } + _, exist := departments[departmentsInfo[department.ID]] + if exist { + p.Departments[department.ID] = departmentsInfo[department.ID] + } else if _, parentExist := p.Departments[department.ParentID]; parentExist { + p.Departments[department.ID] = departmentsInfo[department.ID] + } + } + if len(departments) > len(p.Departments) { + log.Printf("Incorrect departments whitelist %v in remote departments %#v", departments, departmentsInfo) + } else { + log.Printf("Prepremitted departments %v", p.Departments) + } + } + return +} + +func (p *DingTalkProvider) RefreshCorpAccessToken() (err error) { + params := url.Values{} + params.Add("appkey", p.CorpAccessToken.CorpID) + params.Add("appsecret", p.CorpAccessToken.CorpSSOSecret) + getCorpAccessTokenURL := &url.URL{ + Scheme: p.RedeemURL.Scheme, + Host: p.RedeemURL.Host, + Path: "/gettoken", + RawQuery: params.Encode(), + } + var req *http.Request + req, err = http.NewRequest("GET", getCorpAccessTokenURL.String(), nil) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + var corpAccessTokenResp struct { + AccessToken string `json:"access_token"` + ErrorCode int `json:"errcode"` + ErrMessage string `json:"errmsg"` + } + err = api.RequestJson(req, &corpAccessTokenResp) + if err != nil { + return err + } + if corpAccessTokenResp.ErrorCode != 0 { + err = fmt.Errorf("got error code %d from %q %s", corpAccessTokenResp.ErrorCode, getCorpAccessTokenURL.String(), corpAccessTokenResp.ErrMessage) + return + } + p.CorpAccessToken.AccessToken = corpAccessTokenResp.AccessToken + p.CorpAccessToken.ExpiresOn = time.Now().Add(time.Duration(7200-60) * time.Second).Truncate(time.Second) + return nil +} + +func (p *DingTalkProvider) GetEmailAddress(s *SessionState) (string, error) { + /** + Get union id of user + https://open-doc.dingtalk.com/microapp/serverapi2/athq8o#a-nameafurtga%E8%8E%B7%E5%8F%96%E7%94%A8%E6%88%B7%E6%8E%88%E6%9D%83%E7%9A%84%E4%B8%AA%E4%BA%BA%E4%BF%A1%E6%81%AF + */ + params := url.Values{} + params.Add("sns_token", s.AccessToken) + getSNSUserURL := &url.URL{ + Scheme: p.RedeemURL.Scheme, + Host: p.RedeemURL.Host, + Path: "/sns/getuserinfo", + RawQuery: params.Encode(), + } + + req, err := http.NewRequest("GET", getSNSUserURL.String(), nil) + if err != nil { + return "", fmt.Errorf("could not create new GET request: %v", err) + } + + var snsUserResp struct { + ErrorCode int `json:"errcode"` + ErrMessage string `json:"errmsg"` + User struct { + Nick string `json:"nick"` + OpenID string `json:"openid"` + UnionID string `json:"unionid"` + } `json:"user_info"` + } + err = api.RequestJson(req, &snsUserResp) + if err != nil { + return "", err + } + if snsUserResp.ErrorCode != 0 { + err = fmt.Errorf("got error code %d from %q %s", snsUserResp.ErrorCode, getSNSUserURL.String(), snsUserResp.ErrMessage) + return "", err + } + + /** + Get user id of user in corp + https://open-doc.dingtalk.com/microapp/serverapi2/ege851#a-names9a%E6%A0%B9%E6%8D%AEunionid%E8%8E%B7%E5%8F%96userid + */ + params = url.Values{} + if p.CorpAccessToken.ExpiresOn.Before(time.Now()) { + err = p.RefreshCorpAccessToken() + if err != nil { + return "", err + } + } + params.Add("access_token", p.CorpAccessToken.AccessToken) + params.Add("unionid", snsUserResp.User.UnionID) + getUserIDByUnionIDURL := &url.URL{ + Scheme: p.RedeemURL.Scheme, + Host: p.RedeemURL.Host, + Path: "/user/getUseridByUnionid", + RawQuery: params.Encode(), + } + + req, err = http.NewRequest("GET", getUserIDByUnionIDURL.String(), nil) + if err != nil { + return "", fmt.Errorf("could not create new GET request: %v", err) + } + + var userIDResp struct { + ErrorCode int `json:"errcode"` + ErrMessage string `json:"errmsg"` + ContactType int `json:"contactType"` + UserID string `json:"userid"` + } + err = api.RequestJson(req, &userIDResp) + if err != nil { + return "", err + } + if userIDResp.ErrorCode != 0 { + err = fmt.Errorf("got error code %d from %q %s", userIDResp.ErrorCode, getUserIDByUnionIDURL.String(), userIDResp.ErrMessage) + return "", err + } + + /** + Get user info in corp + https://open-doc.dingtalk.com/microapp/serverapi2/ege851#a-names1a%E8%8E%B7%E5%8F%96%E7%94%A8%E6%88%B7%E8%AF%A6%E6%83%85 + */ + params = url.Values{} + params.Add("access_token", p.CorpAccessToken.AccessToken) + params.Add("userid", userIDResp.UserID) + getUserInfoURL := &url.URL{ + Scheme: p.RedeemURL.Scheme, + Host: p.RedeemURL.Host, + Path: "/user/get", + RawQuery: params.Encode(), + } + + req, err = http.NewRequest("GET", getUserInfoURL.String(), nil) + if err != nil { + return "", fmt.Errorf("could not create new GET request: %v", err) + } + + var userResp struct { + ErrorCode int `json:"errcode"` + ErrMessage string `json:"errmsg"` + UserID string `json:"userid"` + Name string `json:"name"` + Email string `json:"email"` + OrgEmail string `json:"orgEmail"` + Mobile string `json:"mobile"` + Department []int64 `json:"department"` + } + err = api.RequestJson(req, &userResp) + if err != nil { + return "", err + } + if userIDResp.ErrorCode != 0 { + err = fmt.Errorf("got error code %d from %q %s", userResp.ErrorCode, getUserInfoURL.String(), userResp.ErrMessage) + return "", err + } + + if p.Departments == nil || p.hasDepartment(userResp.Department) { + s.User = userResp.Name + s.Email = userResp.OrgEmail + if s.Email == "" { + s.Email = userResp.Email + } + } + + return s.Email, nil +} + +func (p *DingTalkProvider) hasDepartment(departments []int64) bool { + for _, departID := range departments { + if _, exist := p.Departments[departID]; exist { + return true + } + } + log.Printf("Missing Department in white list:%v in %#v", departments, p.Departments) + return false +} diff --git a/providers/providers.go b/providers/providers.go index 70e707b43..385cf971d 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -31,6 +31,8 @@ func New(provider string, p *ProviderData) Provider { return NewGitLabProvider(p) case "oidc": return NewOIDCProvider(p) + case "dingtalk": + return NewDingTalkProvider(p) default: return NewGoogleProvider(p) } diff --git a/watcher.go b/watcher.go index bedb9f893..9888fe5b8 100644 --- a/watcher.go +++ b/watcher.go @@ -8,7 +8,7 @@ import ( "path/filepath" "time" - "gopkg.in/fsnotify.v1" + "gopkg.in/fsnotify/fsnotify.v1" ) func WaitForReplacement(filename string, op fsnotify.Op,