Skip to content

Commit

Permalink
Merge pull request #92 from JesusIslam/master
Browse files Browse the repository at this point in the history
[Feature] Adding Gitlab Provider
  • Loading branch information
smancke committed Jan 3, 2019
2 parents 7a8566d + 1887816 commit 2bb2308
Show file tree
Hide file tree
Showing 8 changed files with 487 additions and 18 deletions.
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ The following providers (login backends) are supported.
* Google login
* Bitbucket login
* Facebook login

* Gitlab login

## Questions

For questions and support please use the [Gitter chat room](https://gitter.im/tarent/loginsrv).
Expand All @@ -54,6 +55,7 @@ _Note for Caddy users_: Not all parameters are available in Caddy. See the table
| -google | value | | X | OAuth config in the form: client_id=..,client_secret=..,scope=..[redirect_uri=..] |
| -bitbucket | value | | X | OAuth config in the form: client_id=..,client_secret=..,[,scope=..][redirect_uri=..] |
| -facebook | value | | X | OAuth config in the form: client_id=..,client_secret=..,scope=email..[redirect_uri=..] |
| -gitlab | value | | X | OAuth config in the form: client_id=..,client_secret=..[,scope=..,][redirect_uri=..]
| -host | string | "localhost" | - | Host to listen on |
| -htpasswd | value | | X | Htpasswd login backend opts: file=/path/to/pwdfile |
| -jwt-expiry | go duration | 24h | X | Expiry duration for the JWT token, e.g. 2h or 3h30m |
Expand Down Expand Up @@ -296,6 +298,7 @@ Currently the following OAuth provider is supported:
* Google (see note below)
* Bitbucket
* Facebook (see note below)
* Gitlab

An OAuth provider supports the following parameters:

Expand Down Expand Up @@ -374,11 +377,13 @@ below the claim attribute are written into the token. The following attributes c
* `origin` - the provider or backend name (all backends)
* `email` - the mail address (the OAuth provider)
* `domain` - the domain (Google only)
* `groups` - the full path string of user groups enclosed in an array (Gitlab only)

Example:
* The user bob will become the `"role": "superAdmin"`, when authenticating with htpasswd file
* The user admin@example.org will become `"role": "admin"` and `"projects": ["example"]`, when authenticating with Google OAuth
* All other Google users with the domain example will become `"role": "user"` and `"projects": ["example"]`
* All other Gitlab users with group `example/subgroup` and `othergroup` will become `"role": "admin"`.
* All others will become `"role": "unknown"`, indenpendent of the authentication provider

```
Expand All @@ -401,6 +406,13 @@ Example:
projects:
- example
- groups:
- example/subgroup
- othergroup
origin: gitlab
claims:
role: admin
- claims:
role: unknown
```
20 changes: 18 additions & 2 deletions login/user_claims.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package login

import (
"io/ioutil"
"time"

"github.com/dgrijalva/jwt-go"
"github.com/pkg/errors"
"github.com/tarent/loginsrv/model"
"gopkg.in/yaml.v2"
"io/ioutil"
"time"
)

type customClaims map[string]interface{}
Expand All @@ -27,6 +28,7 @@ type userFileEntry struct {
Origin string `yaml:"origin"`
Email string `yaml:"email"`
Domain string `yaml:"domain"`
Groups []string `yaml:"groups"`
Claims map[string]interface{} `yaml:"claims"`
}

Expand Down Expand Up @@ -87,5 +89,19 @@ func match(userInfo model.UserInfo, entry userFileEntry) bool {
if entry.Origin != "" && entry.Origin != userInfo.Origin {
return false
}
if len(entry.Groups) > 0 {
eligible := false
for _, entryGroup := range entry.Groups {
for _, userGroup := range userInfo.Groups {
if entryGroup == userGroup {
eligible = true
break
}
}
}
if !eligible {
return false
}
}
return true
}
74 changes: 70 additions & 4 deletions login/user_claims_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package login

import (
. "github.com/stretchr/testify/assert"
"github.com/tarent/loginsrv/model"
"io/ioutil"
"os"
"testing"
"time"

. "github.com/stretchr/testify/assert"
"github.com/tarent/loginsrv/model"
)

var claimsExample = `
Expand All @@ -29,10 +31,45 @@ var claimsExample = `
projects:
- example
- origin: gitlab
groups:
- "example/subgroup"
- othergroup
claims:
role: admin
- claims:
role: unknown
`

var invalidClaimsExample = `
- sub: bob
origin: google
`

func Test_ParseUserClaims_InvalidFile(t *testing.T) {
c, err := NewUserClaims(&Config{UserFile: "notfound"})
Error(t, err)
Equal(t, &UserClaims{
userFile: "notfound",
userFileEntries: []userFileEntry{},
}, c)
}

func Test_ParseUserClaims_InvalidYAML(t *testing.T) {
f, _ := ioutil.TempFile("", "")
f.WriteString(invalidClaimsExample)
f.Close()
defer os.Remove(f.Name())

c, err := NewUserClaims(&Config{UserFile: f.Name()})
Error(t, err)
Equal(t, &UserClaims{
userFile: f.Name(),
userFileEntries: []userFileEntry{},
}, c)
}

func Test_UserClaims_ParseUserClaims(t *testing.T) {
f, _ := ioutil.TempFile("", "")
f.WriteString(claimsExample)
Expand All @@ -41,11 +78,12 @@ func Test_UserClaims_ParseUserClaims(t *testing.T) {

c, err := NewUserClaims(&Config{UserFile: f.Name()})
NoError(t, err)
Equal(t, 4, len(c.userFileEntries))
Equal(t, 5, len(c.userFileEntries))
Equal(t, "admin@example.org", c.userFileEntries[1].Email)
Equal(t, "google", c.userFileEntries[1].Origin)
Equal(t, "admin", c.userFileEntries[1].Claims["role"])
Equal(t, []interface{}{"example"}, c.userFileEntries[1].Claims["projects"])
Equal(t, []string{"example/subgroup", "othergroup"}, c.userFileEntries[3].Groups)
}

func Test_UserClaims_Claims(t *testing.T) {
Expand All @@ -65,6 +103,10 @@ func Test_UserClaims_Claims(t *testing.T) {
claims, _ = c.Claims(model.UserInfo{Sub: "any", Email: "admin@example.org", Origin: "google"})
Equal(t, customClaims{"sub": "overwrittenSubject", "email": "admin@example.org", "origin": "google", "role": "admin", "projects": []interface{}{"example"}}, claims)

// Match fourth entry
claims, _ = c.Claims(model.UserInfo{Sub: "any", Groups: []string{"example/subgroup", "othergroup"}, Origin: "gitlab"})
Equal(t, customClaims{"sub": "any", "groups": []string{"example/subgroup", "othergroup"}, "origin": "gitlab", "role": "admin"}, claims)

// default case with no rules
claims, _ = c.Claims(model.UserInfo{Sub: "bob"})
Equal(t, customClaims{"sub": "bob", "role": "unknown"}, claims)
Expand All @@ -74,6 +116,8 @@ func Test_UserClaims_NoMatch(t *testing.T) {
f, _ := ioutil.TempFile("", "")
f.WriteString(`
- sub: bob
groups:
- othergroup
claims:
role: superAdmin
`)
Expand All @@ -83,8 +127,30 @@ func Test_UserClaims_NoMatch(t *testing.T) {
c, err := NewUserClaims(&Config{UserFile: f.Name()})
NoError(t, err)

// Mo Match -> not Modified
// No Match -> not Modified
claims, err := c.Claims(model.UserInfo{Sub: "foo"})
NoError(t, err)
Equal(t, model.UserInfo{Sub: "foo"}, claims)

claims, err = c.Claims(model.UserInfo{Sub: "bob", Groups: []string{"group"}})
NoError(t, err)
Equal(t, model.UserInfo{Sub: "bob", Groups: []string{"group"}}, claims)
}

func Test_UserClaims_Valid(t *testing.T) {
cc := customClaims{
"exp": time.Now().Unix() + 3600,
}

err := cc.Valid()
NoError(t, err)
}

func Test_UserClaims_Invalid(t *testing.T) {
cc := customClaims{
"exp": time.Now().Unix() - 3600,
}

err := cc.Valid()
Error(t, err)
}
20 changes: 12 additions & 8 deletions model/user_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import (
// UserInfo holds the parameters returned by the backends.
// This information will be serialized to build the JWT token contents.
type UserInfo struct {
Sub string `json:"sub"`
Picture string `json:"picture,omitempty"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Origin string `json:"origin,omitempty"`
Expiry int64 `json:"exp,omitempty"`
Refreshes int `json:"refs,omitempty"`
Domain string `json:"domain,omitempty"`
Sub string `json:"sub"`
Picture string `json:"picture,omitempty"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Origin string `json:"origin,omitempty"`
Expiry int64 `json:"exp,omitempty"`
Refreshes int `json:"refs,omitempty"`
Domain string `json:"domain,omitempty"`
Groups []string `json:"groups,omitempty"`
}

// Valid lets us use the user info as Claim for jwt-go.
Expand Down Expand Up @@ -52,5 +53,8 @@ func (u UserInfo) AsMap() map[string]interface{} {
if u.Domain != "" {
m["domain"] = u.Domain
}
if len(u.Groups) > 0 {
m["groups"] = u.Groups
}
return m
}
4 changes: 3 additions & 1 deletion model/user_info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ package model

import (
"encoding/json"
. "github.com/stretchr/testify/assert"
"testing"
"time"

. "github.com/stretchr/testify/assert"
)

func Test_UserInfo_Valid(t *testing.T) {
Expand All @@ -23,6 +24,7 @@ func Test_UserInfo_AsMap(t *testing.T) {
Expiry: 23,
Refreshes: 42,
Domain: `json:"domain,omitempty"`,
Groups: []string{`json:"groups,omitempty"`},
}

givenJson, _ := json.Marshal(u.AsMap())
Expand Down
106 changes: 106 additions & 0 deletions oauth2/gitlab.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package oauth2

import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"

"github.com/tarent/loginsrv/model"
)

var gitlabAPI = "https://gitlab.com/api/v4"

func init() {
RegisterProvider(providerGitlab)
}

// GitlabUser is used for parsing the gitlab response
type GitlabUser struct {
Username string `json:"username,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
}

type GitlabGroup struct {
FullPath string `json:"full_path,omitempty"`
}

var providerGitlab = Provider{
Name: "gitlab",
AuthURL: "https://gitlab.com/oauth/authorize",
TokenURL: "https://gitlab.com/oauth/token",
GetUserInfo: func(token TokenInfo) (model.UserInfo, string, error) {
gu := GitlabUser{}
url := fmt.Sprintf("%v/user?access_token=%v", gitlabAPI, token.AccessToken)

var respUser *http.Response
respUser, err := http.Get(url)
if err != nil {
return model.UserInfo{}, "", err
}
defer respUser.Body.Close()

if !strings.Contains(respUser.Header.Get("Content-Type"), "application/json") {
return model.UserInfo{}, "", fmt.Errorf("wrong content-type on gitlab get user info: %v", respUser.Header.Get("Content-Type"))
}

if respUser.StatusCode != 200 {
return model.UserInfo{}, "", fmt.Errorf("got http status %v on gitlab get user info", respUser.StatusCode)
}

b, err := ioutil.ReadAll(respUser.Body)
if err != nil {
return model.UserInfo{}, "", fmt.Errorf("error reading gitlab get user info: %v", err)
}

err = json.Unmarshal(b, &gu)
if err != nil {
return model.UserInfo{}, "", fmt.Errorf("error parsing gitlab get user info: %v", err)
}

gg := []*GitlabGroup{}
url = fmt.Sprintf("%v/groups?access_token=%v", gitlabAPI, token.AccessToken)

var respGroup *http.Response
respGroup, err = http.Get(url)
if err != nil {
return model.UserInfo{}, "", err
}
defer respGroup.Body.Close()

if !strings.Contains(respGroup.Header.Get("Content-Type"), "application/json") {
return model.UserInfo{}, "", fmt.Errorf("wrong content-type on gitlab get groups info: %v", respGroup.Header.Get("Content-Type"))
}

if respGroup.StatusCode != 200 {
return model.UserInfo{}, "", fmt.Errorf("got http status %v on gitlab get groups info", respGroup.StatusCode)
}

g, err := ioutil.ReadAll(respGroup.Body)
if err != nil {
return model.UserInfo{}, "", fmt.Errorf("error reading gitlab get groups info: %v", err)
}

err = json.Unmarshal(g, &gg)
if err != nil {
return model.UserInfo{}, "", fmt.Errorf("error parsing gitlab get groups info: %v", err)
}

groups := make([]string, len(gg))
for i := 0; i < len(gg); i++ {
groups[i] = gg[i].FullPath
}

return model.UserInfo{
Sub: gu.Username,
Picture: gu.AvatarURL,
Name: gu.Name,
Email: gu.Email,
Groups: groups,
Origin: "gitlab",
}, `{"user":` + string(b) + `,"groups":` + string(g) + `}`, nil
},
}
Loading

0 comments on commit 2bb2308

Please sign in to comment.