connector: add GitLab connector

This commit is contained in:
Ali Javadi 2017-01-24 21:26:52 +03:30 committed by Ali Javadi
parent 48fcf66a35
commit e623ad4d35
5 changed files with 377 additions and 0 deletions

View file

@ -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
```

View file

@ -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) },
}

288
connector/gitlab/gitlab.go Normal file
View file

@ -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
}

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="500px" height="500px" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch -->
<title>logo-square</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="logo-square" sketch:type="MSArtboardGroup">
<g id="logo-no-bg" sketch:type="MSLayerGroup" transform="translate(2.000000, 19.000000)">
<g id="Page-1" sketch:type="MSShapeGroup">
<g id="gitlab_logo">
<g id="g10" transform="translate(248.000000, 228.833300) scale(1, -1) translate(-248.000000, -228.833300) translate(0.000000, 0.333300)">
<g id="g16">
<g id="g18-Clipped">
<g id="g18">
<g>
<g id="Group" transform="translate(0.666658, 0.666658)">
<g id="g44" transform="translate(0.532000, 0.774933)" fill="#FC6D26">
<path d="M491.999988,194.666662 L464.441322,279.481326 L409.82399,447.578655 C407.014656,456.226655 394.778657,456.226655 391.96799,447.578655 L337.349325,279.481326 L155.982663,279.481326 L101.362664,447.578655 C98.5533309,456.226655 86.3173312,456.226655 83.5066646,447.578655 L28.8893326,279.481326 L1.33199997,194.666662 C-1.18266664,186.930662 1.57199996,178.455996 8.1519998,173.674662 L246.665327,0.385333324 L485.179988,173.674662 C491.759988,178.455996 494.513321,186.930662 491.999988,194.666662" id="path46"></path>
</g>
<g id="g48" transform="translate(156.197863, 1.160267)" fill="#E24329">
<path d="M90.9999977,0 L90.9999977,0 L181.683995,279.095993 L0.31599997,279.095993 L90.9999977,0 L90.9999977,0 Z" id="path50"></path>
</g>
<g id="g56" transform="translate(28.531199, 1.160800)" fill="#FC6D26">
<path d="M218.666661,0 L127.982663,279.09466 L0.890666644,279.09466 L218.666661,0 L218.666661,0 Z" id="path58"></path>
</g>
<g id="g64" transform="translate(0.088533, 0.255867)" fill="#FCA326">
<path d="M29.3333326,279.999993 L29.3333326,279.999993 L1.77466662,195.185328 C-0.738666648,187.449329 2.01466662,178.974662 8.59599979,174.194662 L247.109327,0.905333311 L29.3333326,279.999993 L29.3333326,279.999993 Z" id="path66"></path>
</g>
<g id="g72" transform="translate(29.421866, 280.255593)" fill="#E24329">
<path d="M0,0 L127.091997,0 L72.4733315,168.097329 C69.6626649,176.746662 57.4266652,176.746662 54.617332,168.097329 L0,0 L0,0 Z" id="path74"></path>
</g>
<g id="g76" transform="translate(247.197860, 1.160800)" fill="#FC6D26">
<path d="M0,0 L90.6839977,279.09466 L217.775995,279.09466 L0,0 L0,0 Z" id="path78"></path>
</g>
<g id="g80" transform="translate(246.307061, 0.255867)" fill="#FCA326">
<path d="M218.666661,279.999993 L218.666661,279.999993 L246.225327,195.185328 C248.73866,187.449329 245.985327,178.974662 239.403994,174.194662 L0.890666644,0.905333311 L218.666661,279.999993 L218.666661,279.999993 Z" id="path82"></path>
</g>
<g id="g84" transform="translate(336.973725, 280.255593)" fill="#E24329">
<path d="M127.999997,0 L0.907999977,0 L55.5266653,168.097329 C58.3373319,176.746662 70.5733316,176.746662 73.3826648,168.097329 L127.999997,0 L127.999997,0 Z" id="path86"></path>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -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);