Skip to content

Commit

Permalink
Merge branch 'pr/eikehartmann/205'
Browse files Browse the repository at this point in the history
  • Loading branch information
bnfinet committed Mar 12, 2020
2 parents 561e102 + 35dbe19 commit 8dce88f
Show file tree
Hide file tree
Showing 17 changed files with 1,027 additions and 380 deletions.
11 changes: 11 additions & 0 deletions config/config.yml_example_github
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ vouch:
# set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at GitHub
# allowAllUsers: true

# set teamWhitelist: to list of teams and/or GitHub organizations
# When putting an organization id without a slash, it will allow all (public) members from the organization.
# The client will try to read the private organization membership using the client credentials, if that's not possible
# due to access restriction, it will try to evaluate the publicly visible membership.
# Allowing members form a specific team can be configured by qualifying the team with the organization, separated by
# a slash.
# teamWhitelist:
# - myOrg
# - myOrg/myTeam
# In case both vouch.teamWhitelist AND oauth.scopes is configured, make sure read:org scope is included

oauth:
# create a new OAuth application at:
# https://github.com/settings/applications/new
Expand Down
15 changes: 15 additions & 0 deletions config/config.yml_example_github_enterprise
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ vouch:
# instead of setting specific domains you may prefer to allow all users...
# set allowAllUsers: true to use Vouch Proxy to just accept anyone who can authenticate at the configured provider
# allowAllUsers: true
# set teamWhitelist: to list of teams and/or GitHub organizations
# When putting an organization id without a slash, it will allow all (public) members from the organization.
# The client will try to read the private organization membership using the client credentials, if that's not possible
# due to access restriction, it will try to evaluate the publicly visible membership.
# Allowing members form a specific team can be configured by qualifying the team with the organization, separated by
# a slash.
# teamWhitelist:
# - myOrg
# - myOrg/myTeam
# In case both vouch.teamWhitelist AND oauth.scopes is configured, make sure read:org scope is included

oauth:
# create a new OAuth application at:
Expand All @@ -25,6 +35,11 @@ oauth:
auth_url: https://githubenterprise.yoursite.com/login/oauth/authorize
token_url: https://githubenterprise.yoursite.com/login/oauth/access_token
user_info_url: https://githubenterprise.yoursite.com/api/v3/user?access_token=
# relevant only if teamWhitelist is configured; colon-prefixed parts are parameters that
# will be replaced with the respective values.
user_team_url: https://githubenterprise.yoursite.com/api/v3/orgs/:org_id/teams/:team_slug/memberships/:username?access_token=
user_org_url: https://githubenterprise.yoursite.com/api/v3/orgs/:org_id/members/:username?access_token=
# these GitHub OAuth defaults are set for you..
# scopes:
# - user
# In case both vouch.teamWhitelist AND oauth.scopes is configured, make sure read:org scope is included
112 changes: 112 additions & 0 deletions handlers/adfs/adfs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package adfs

import (
"encoding/base64"
"encoding/json"
"github.com/vouch/vouch-proxy/handlers/common"
"github.com/vouch/vouch-proxy/pkg/cfg"
"github.com/vouch/vouch-proxy/pkg/structs"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
)

type Handler struct{}

type adfsTokenRes struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
IDToken string `json:"id_token"`
ExpiresIn int64 `json:"expires_in"` // relative seconds from now
}

var (
log = cfg.Cfg.Logger
)

// More info: https://docs.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-scenarios-for-developers#supported-scenarios
func (Handler) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens) (rerr error) {
code := r.URL.Query().Get("code")
log.Debugf("code: %s", code)

formData := url.Values{}
formData.Set("code", code)
formData.Set("grant_type", "authorization_code")
formData.Set("resource", cfg.GenOAuth.RedirectURL)
formData.Set("client_id", cfg.GenOAuth.ClientID)
formData.Set("redirect_uri", cfg.GenOAuth.RedirectURL)
if cfg.GenOAuth.ClientSecret != "" {
formData.Set("client_secret", cfg.GenOAuth.ClientSecret)
}
req, err := http.NewRequest("POST", cfg.GenOAuth.TokenURL, strings.NewReader(formData.Encode()))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(formData.Encode())))
req.Header.Set("Accept", "application/json")

client := &http.Client{}
userinfo, err := client.Do(req)

if err != nil {
return err
}
defer func() {
if err := userinfo.Body.Close(); err != nil {
rerr = err
}
}()

data, _ := ioutil.ReadAll(userinfo.Body)
tokenRes := adfsTokenRes{}

if err := json.Unmarshal(data, &tokenRes); err != nil {
log.Errorf("oauth2: cannot fetch token: %v", err)
return nil
}

ptokens.PAccessToken = string(tokenRes.AccessToken)
ptokens.PIdToken = string(tokenRes.IDToken)

s := strings.Split(tokenRes.IDToken, ".")
if len(s) < 2 {
log.Error("jws: invalid token received")
return nil
}

idToken, err := base64.RawURLEncoding.DecodeString(s[1])
if err != nil {
log.Error(err)
return nil
}
log.Debugf("idToken: %+v", string(idToken))

adfsUser := structs.ADFSUser{}
json.Unmarshal([]byte(idToken), &adfsUser)
log.Infof("adfs adfsUser: %+v", adfsUser)
// data contains an access token, refresh token, and id token
// Please note that in order for custom claims to work you MUST set allatclaims in ADFS to be passed
// https://oktotechnologies.ca/2018/08/26/adfs-openidconnect-configuration/
if err = common.MapClaims([]byte(idToken), customClaims); err != nil {
log.Error(err)
return err
}
adfsUser.PrepareUserData()
var rxEmail = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")

if len(adfsUser.Email) == 0 {
// If the email is blank, we will try to determine if the UPN is an email.
if rxEmail.MatchString(adfsUser.UPN) {
// Set the email from UPN if there is a valid email present.
adfsUser.Email = adfsUser.UPN
}
}
user.Username = adfsUser.Username
user.Email = adfsUser.Email
log.Debugf("User Obj: %+v", user)
return nil
}
61 changes: 61 additions & 0 deletions handlers/common/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package common

import (
"context"
"encoding/json"
"github.com/vouch/vouch-proxy/pkg/cfg"
"github.com/vouch/vouch-proxy/pkg/structs"
"golang.org/x/oauth2"
"net/http"
)

var (
log = cfg.Cfg.Logger
)

func PrepareTokensAndClient(r *http.Request, ptokens *structs.PTokens, setpid bool) (error, *http.Client, *oauth2.Token) {
providerToken, err := cfg.OAuthClient.Exchange(context.TODO(), r.URL.Query().Get("code"))
if err != nil {
return err, nil, nil
}
ptokens.PAccessToken = providerToken.AccessToken

if setpid {
if providerToken.Extra("id_token") != nil {
// Certain providers (eg. gitea) don't provide an id_token
// and it's not neccessary for the authentication phase
ptokens.PIdToken = providerToken.Extra("id_token").(string)
} else {
log.Debugf("id_token missing - may not be supported by this provider")
}
}

log.Debugf("ptokens: %+v", ptokens)

client := cfg.OAuthClient.Client(context.TODO(), providerToken)
return err, client, providerToken
}

func MapClaims(claims []byte, customClaims *structs.CustomClaims) error {
// Create a struct that contains the claims that we want to store from the config.
var f interface{}
err := json.Unmarshal(claims, &f)
if err != nil {
log.Error("Error unmarshaling claims")
return err
}
m := f.(map[string]interface{})
for k := range m {
var found = false
for _, e := range cfg.Cfg.Headers.Claims {
if k == e {
found = true
}
}
if found == false {
delete(m, k)
}
}
customClaims.Claims = m
return nil
}
168 changes: 168 additions & 0 deletions handlers/github/github.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package github

import (
"encoding/json"
"errors"
"github.com/vouch/vouch-proxy/handlers/common"
"github.com/vouch/vouch-proxy/pkg/cfg"
"github.com/vouch/vouch-proxy/pkg/structs"
"golang.org/x/oauth2"
"io/ioutil"
"net/http"
"strings"
)

type Handler struct {
PrepareTokensAndClient func(*http.Request, *structs.PTokens, bool) (error, *http.Client, *oauth2.Token)
}

var (
log = cfg.Cfg.Logger
)

// github
// https://developer.github.com/apps/building-integrations/setting-up-and-registering-oauth-apps/about-authorization-options-for-oauth-apps/
func (me Handler) GetUserInfo(r *http.Request, user *structs.User, customClaims *structs.CustomClaims, ptokens *structs.PTokens) (rerr error) {
err, client, ptoken := me.PrepareTokensAndClient(r, ptokens, true)
if err != nil {
// http.Error(w, err.Error(), http.StatusBadRequest)
return err
}
log.Errorf("ptoken.AccessToken: %s", ptoken.AccessToken)
userinfo, err := client.Get(cfg.GenOAuth.UserInfoURL + ptoken.AccessToken)
if err != nil {
// http.Error(w, err.Error(), http.StatusBadRequest)
return err
}
defer func() {
if err := userinfo.Body.Close(); err != nil {
rerr = err
}
}()
data, _ := ioutil.ReadAll(userinfo.Body)
log.Infof("github userinfo body: %s", string(data))
if err = common.MapClaims(data, customClaims); err != nil {
log.Error(err)
return err
}
ghUser := structs.GitHubUser{}
if err = json.Unmarshal(data, &ghUser); err != nil {
log.Error(err)
return err
}
log.Debug("getUserInfoFromGitHub ghUser")
log.Debug(ghUser)
log.Debug("getUserInfoFromGitHub user")
log.Debug(user)

ghUser.PrepareUserData()
user.Email = ghUser.Email
user.Name = ghUser.Name
user.Username = ghUser.Username
user.ID = ghUser.ID

// user = &ghUser.User

toOrgAndTeam := func(orgAndTeam string) (string, string) {
split := strings.Split(orgAndTeam, "/")
if len(split) == 1 {
// only organization given
return orgAndTeam, ""
} else if len(split) == 2 {
return split[0], split[1]
} else {
return "", ""
}
}

if len(cfg.Cfg.TeamWhiteList) != 0 {
for _, orgAndTeam := range cfg.Cfg.TeamWhiteList {
org, team := toOrgAndTeam(orgAndTeam)
if org != "" {
log.Info(org)
var (
e error
isMember bool
)
if team != "" {
e, isMember = getTeamMembershipStateFromGitHub(client, user, org, team, ptoken)
} else {
e, isMember = getOrgMembershipStateFromGitHub(client, user, org, ptoken)
}
if e != nil {
return e
} else {
if isMember {
user.TeamMemberships = append(user.TeamMemberships, orgAndTeam)
}
}
} else {
log.Warnf("Invalid org/team format in %s: must be written as <orgId>/<teamSlug>", orgAndTeam)
}
}
}

log.Debug("getUserInfoFromGitHub")
log.Debug(user)
return nil
}

func getOrgMembershipStateFromGitHub(client *http.Client, user *structs.User, orgId string, ptoken *oauth2.Token) (rerr error, isMember bool) {
replacements := strings.NewReplacer(":org_id", orgId, ":username", user.Username)
orgMembershipResp, err := client.Get(replacements.Replace(cfg.GenOAuth.UserOrgURL) + ptoken.AccessToken)
if err != nil {
log.Error(err)
return err, false
}

if orgMembershipResp.StatusCode == 302 {
log.Debug("Need to check public membership")
location := orgMembershipResp.Header.Get("Location")
if location != "" {
orgMembershipResp, err = client.Get(location)
}
}

if orgMembershipResp.StatusCode == 204 {
log.Debug("getOrgMembershipStateFromGitHub isMember: true")
return nil, true
} else if orgMembershipResp.StatusCode == 404 {
log.Debug("getOrgMembershipStateFromGitHub isMember: false")
return nil, false
} else {
log.Errorf("getOrgMembershipStateFromGitHub: unexpected status code %d", orgMembershipResp.StatusCode)
return errors.New("Unexpected response status " + orgMembershipResp.Status), false
}
}

func getTeamMembershipStateFromGitHub(client *http.Client, user *structs.User, orgId string, team string, ptoken *oauth2.Token) (rerr error, isMember bool) {
replacements := strings.NewReplacer(":org_id", orgId, ":team_slug", team, ":username", user.Username)
membershipStateResp, err := client.Get(replacements.Replace(cfg.GenOAuth.UserTeamURL) + ptoken.AccessToken)
if err != nil {
log.Error(err)
return err, false
}
defer func() {
if err := membershipStateResp.Body.Close(); err != nil {
rerr = err
}
}()
if membershipStateResp.StatusCode == 200 {
data, _ := ioutil.ReadAll(membershipStateResp.Body)
log.Infof("github team membership body: ", string(data))
ghTeamState := structs.GitHubTeamMembershipState{}
if err = json.Unmarshal(data, &ghTeamState); err != nil {
log.Error(err)
return err, false
}
log.Debug("getTeamMembershipStateFromGitHub ghTeamState")
log.Debug(ghTeamState)
return nil, ghTeamState.State == "active"
} else if membershipStateResp.StatusCode == 404 {
log.Debug("getTeamMembershipStateFromGitHub isMember: false")
return nil, false
} else {
log.Errorf("getTeamMembershipStateFromGitHub: unexpected status code %d", membershipStateResp.StatusCode)
return errors.New("Unexpected response status " + membershipStateResp.Status), false
}
}
Loading

0 comments on commit 8dce88f

Please sign in to comment.