From e623ad4d35d5b341c62c08db4109d2c7b9c82bb6 Mon Sep 17 00:00:00 2001 From: Ali Javadi Date: Tue, 24 Jan 2017 21:26:52 +0330 Subject: [PATCH] connector: add GitLab connector --- Documentation/gitlab-connector.md | 29 +++ cmd/dex/config.go | 2 + connector/gitlab/gitlab.go | 288 ++++++++++++++++++++++++++++++ web/static/img/gitlab-icon.svg | 53 ++++++ web/static/main.css | 5 + 5 files changed, 377 insertions(+) create mode 100644 Documentation/gitlab-connector.md create mode 100644 connector/gitlab/gitlab.go create mode 100644 web/static/img/gitlab-icon.svg diff --git a/Documentation/gitlab-connector.md b/Documentation/gitlab-connector.md new file mode 100644 index 00000000..fc797e9f --- /dev/null +++ b/Documentation/gitlab-connector.md @@ -0,0 +1,29 @@ +# Authentication through Gitlab + +## Overview + +GitLab is a web-based Git repository manager with wiki and issue tracking features, using an open source license, developed by GitLab Inc. One of the login options for dex uses the GitLab OAuth2 flow to identify the end user through their GitLab account. You can use this option with [gitlab.com](gitlab.com), GitLab community or enterprise edition. + +When a client redeems a refresh token through dex, dex will re-query GitLab to update user information in the ID Token. To do this, __dex stores a readonly GitLab access token in its backing datastore.__ Users that reject dex's access through GitLab will also revoke all dex clients which authenticated them through GitLab. + +## Configuration + +Register a new application via `User Settings -> Applications` ensuring the callback URL is `(dex issuer)/callback`. For example if dex is listening at the non-root path `https://auth.example.com/dex` the callback would be `https://auth.example.com/dex/callback`. + +The following is an example of a configuration for `examples/config-dev.yaml`: + +```yaml +connectors: + - type: gitlab + # Required field for connector id. + id: gitlab + # Required field for connector name. + name: GitLab + config: + # optional, default = https://www.gitlab.com + baseURL: https://www.gitlab.com + # Credentials can be string literals or pulled from the environment. + clientID: $GITLAB_APPLICATION_ID + clientSecret: $GITLAB_CLIENT_SECRET + redirectURI: http://127.0.0.1:5556/dex/callback +``` diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 74a1b091..fef06c11 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -11,6 +11,7 @@ import ( "github.com/Sirupsen/logrus" "github.com/coreos/dex/connector" "github.com/coreos/dex/connector/github" + "github.com/coreos/dex/connector/gitlab" "github.com/coreos/dex/connector/ldap" "github.com/coreos/dex/connector/mock" "github.com/coreos/dex/connector/oidc" @@ -182,6 +183,7 @@ var connectors = map[string]func() ConnectorConfig{ "mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) }, "ldap": func() ConnectorConfig { return new(ldap.Config) }, "github": func() ConnectorConfig { return new(github.Config) }, + "gitlab": func() ConnectorConfig { return new(gitlab.Config) }, "oidc": func() ConnectorConfig { return new(oidc.Config) }, "samlExperimental": func() ConnectorConfig { return new(saml.Config) }, } diff --git a/connector/gitlab/gitlab.go b/connector/gitlab/gitlab.go new file mode 100644 index 00000000..0fcc3d26 --- /dev/null +++ b/connector/gitlab/gitlab.go @@ -0,0 +1,288 @@ +// Package gitlab provides authentication strategies using Gitlab. +package gitlab + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "regexp" + "strconv" + + "github.com/Sirupsen/logrus" + "github.com/coreos/dex/connector" + "golang.org/x/net/context" + "golang.org/x/oauth2" +) + +const ( + scopeEmail = "user:email" + scopeOrgs = "read:org" +) + +// Config holds configuration options for gilab logins. +type Config struct { + BaseURL string `json:"baseURL"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectURI"` +} + +type gitlabUser struct { + ID int + Name string + Username string + State string + Email string + IsAdmin bool +} + +type gitlabGroup struct { + ID int + Name string + Path string +} + +// Open returns a strategy for logging in through GitLab. +func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) { + if c.BaseURL == "" { + c.BaseURL = "https://www.gitlab.com" + } + return &gitlabConnector{ + baseURL: c.BaseURL, + redirectURI: c.RedirectURI, + clientID: c.ClientID, + clientSecret: c.ClientSecret, + logger: logger, + }, nil +} + +type connectorData struct { + // GitLab's OAuth2 tokens never expire. We don't need a refresh token. + AccessToken string `json:"accessToken"` +} + +var ( + _ connector.CallbackConnector = (*gitlabConnector)(nil) + _ connector.RefreshConnector = (*gitlabConnector)(nil) +) + +type gitlabConnector struct { + baseURL string + redirectURI string + org string + clientID string + clientSecret string + logger logrus.FieldLogger +} + +func (c *gitlabConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { + gitlabScopes := []string{"api"} + gitlabEndpoint := oauth2.Endpoint{AuthURL: c.baseURL + "/oauth/authorize", TokenURL: c.baseURL + "/oauth/token"} + return &oauth2.Config{ + ClientID: c.clientID, + ClientSecret: c.clientSecret, + Endpoint: gitlabEndpoint, + Scopes: gitlabScopes, + RedirectURL: c.redirectURI, + } +} + +func (c *gitlabConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { + if c.redirectURI != callbackURL { + return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", c.redirectURI, callbackURL) + } + return c.oauth2Config(scopes).AuthCodeURL(state), nil +} + +type oauth2Error struct { + error string + errorDescription string +} + +func (e *oauth2Error) Error() string { + if e.errorDescription == "" { + return e.error + } + return e.error + ": " + e.errorDescription +} + +func (c *gitlabConnector) 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) + ctx := r.Context() + + token, err := oauth2Config.Exchange(ctx, q.Get("code")) + if err != nil { + return identity, fmt.Errorf("gitlab: failed to get token: %v", err) + } + + client := oauth2Config.Client(ctx, token) + + user, err := c.user(ctx, client) + if err != nil { + return identity, fmt.Errorf("gitlab: get user: %v", err) + } + + username := user.Name + if username == "" { + username = user.Email + } + identity = connector.Identity{ + UserID: strconv.Itoa(user.ID), + Username: username, + Email: user.Email, + EmailVerified: true, + } + + if s.Groups { + groups, err := c.groups(ctx, client) + if err != nil { + return identity, fmt.Errorf("gitlab: get groups: %v", err) + } + identity.Groups = groups + } + + if s.OfflineAccess { + data := connectorData{AccessToken: token.AccessToken} + connData, err := json.Marshal(data) + if err != nil { + return identity, fmt.Errorf("marshal connector data: %v", err) + } + identity.ConnectorData = connData + } + + return identity, nil +} + +func (c *gitlabConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { + if len(ident.ConnectorData) == 0 { + return ident, errors.New("no upstream access token found") + } + + var data connectorData + if err := json.Unmarshal(ident.ConnectorData, &data); err != nil { + return ident, fmt.Errorf("gitlab: unmarshal access token: %v", err) + } + + client := c.oauth2Config(s).Client(ctx, &oauth2.Token{AccessToken: data.AccessToken}) + user, err := c.user(ctx, client) + if err != nil { + return ident, fmt.Errorf("gitlab: get user: %v", err) + } + + username := user.Name + if username == "" { + username = user.Email + } + ident.Username = username + ident.Email = user.Email + + if s.Groups { + groups, err := c.groups(ctx, client) + if err != nil { + return ident, fmt.Errorf("gitlab: get groups: %v", err) + } + ident.Groups = groups + } + return ident, nil +} + +// user queries the GitLab API for profile information using the provided client. The HTTP +// client is expected to be constructed by the golang.org/x/oauth2 package, which inserts +// a bearer token as part of the request. +func (c *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlabUser, error) { + var u gitlabUser + req, err := http.NewRequest("GET", c.baseURL+"/api/v3/user", nil) + if err != nil { + return u, fmt.Errorf("gitlab: new req: %v", err) + } + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return u, fmt.Errorf("gitlab: get URL %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return u, fmt.Errorf("gitlab: 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 +} + +// groups queries the GitLab API for group membership. +// +// The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package, +// which inserts a bearer token as part of the request. +func (c *gitlabConnector) groups(ctx context.Context, client *http.Client) ([]string, error) { + + apiURL := c.baseURL + "/api/v3/groups" + + reNext := regexp.MustCompile("<(.*)>; rel=\"next\"") + reLast := regexp.MustCompile("<(.*)>; rel=\"last\"") + + groups := []string{} + var gitlabGroups []gitlabGroup + for { + // 100 is the maximum number for per_page that allowed by gitlab + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("gitlab: new req: %v", err) + } + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("gitlab: get groups: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("gitlab: read body: %v", err) + } + return nil, fmt.Errorf("%s: %s", resp.Status, body) + } + + if err := json.NewDecoder(resp.Body).Decode(&gitlabGroups); err != nil { + return nil, fmt.Errorf("gitlab: unmarshal groups: %v", err) + } + + for _, group := range gitlabGroups { + groups = append(groups, group.Name) + } + + link := resp.Header.Get("Link") + + if len(reLast.FindStringSubmatch(link)) > 1 { + lastPageURL := reLast.FindStringSubmatch(link)[1] + + if apiURL == lastPageURL { + break + } + } else { + break + } + + if len(reNext.FindStringSubmatch(link)) > 1 { + apiURL = reNext.FindStringSubmatch(link)[1] + } else { + break + } + + } + return groups, nil +} diff --git a/web/static/img/gitlab-icon.svg b/web/static/img/gitlab-icon.svg new file mode 100644 index 00000000..e8d408fa --- /dev/null +++ b/web/static/img/gitlab-icon.svg @@ -0,0 +1,53 @@ + + + + logo-square + Created with Sketch. + + + + + + + + + + + \ No newline at end of file diff --git a/web/static/main.css b/web/static/main.css index f33b15ac..008ccce9 100644 --- a/web/static/main.css +++ b/web/static/main.css @@ -62,6 +62,11 @@ body { background-image: url(../static/img/github-icon.svg); } +.dex-btn-icon--gitlab { + background-image: url(../static/img/gitlab-icon.svg); + background-size: contain; +} + .dex-btn-icon--bitbucket { background-color: #205081; background-image: url(../static/img/bitbucket-icon.svg);