Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authorize by GitHub org and or team membership #205

Merged
merged 21 commits into from
Mar 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5507bd6
Check GitHub user team memberships and store them in User struct
eikehartmann Jan 23, 2020
7b0f0a3
Evaluate team memberships when verifying user
eikehartmann Jan 31, 2020
0b99ff8
Remover Org from config and use <orgId>/<teamSlug> as format for gith…
eikehartmann Jan 31, 2020
fd72962
Add test assertions on urls called
eikehartmann Jan 31, 2020
4ad1f13
Add org membership url to config and github defaults
eikehartmann Jan 31, 2020
4a9d20a
Add method to check for github org membership
eikehartmann Jan 31, 2020
6ad9c84
Check for GitHub Org membership if no team qualified in TeamWhiteList…
eikehartmann Jan 31, 2020
227cd5b
Add documentation for teamWhitelist to github example config
eikehartmann Feb 3, 2020
fd1019a
Move Github-related handler stuff to own package
eikehartmann Feb 9, 2020
cfb5be2
Move IndieAuth-related handler stuff to own package
eikehartmann Feb 9, 2020
82f7c18
Move ADFS-related handler stuff to own package
eikehartmann Feb 9, 2020
eacccd5
Move HomeAssistant-related handler stuff to own package
eikehartmann Feb 9, 2020
d367704
Move OpenStax-related handler stuff to own package
eikehartmann Feb 9, 2020
2b6659d
Move Google-related handler stuff to own package
eikehartmann Feb 9, 2020
fb73f16
Move OpenID-related handler stuff to own package
eikehartmann Feb 9, 2020
02dd4da
Refactor to common parameters for different vendor methods
eikehartmann Feb 9, 2020
1244cbd
Use strategy pattern-like switch to select vendor-specific handler
eikehartmann Feb 9, 2020
6e05f94
Add org/team configuration relevant urls to github enterprise sample …
eikehartmann Feb 9, 2020
785ec9f
Add read:org scope if team whitelist is configured for github
eikehartmann Feb 10, 2020
e26ea0c
Improve logging and error handling in github org/team membership retr…
eikehartmann Feb 10, 2020
35dbe19
remove port before testing host
bnfinet Mar 11, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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