diff --git a/README.md b/README.md index ad88331c86..1f9eceacaa 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,51 @@ A list of changes can be seen in the [CHANGELOG](CHANGELOG.md). ![Sign In Page](https://cloud.githubusercontent.com/assets/45028/4970624/7feb7dd8-6886-11e4-93e0-c9904af44ea8.png) +## Epitech Fork + +This fork adds support for an Epitech provider, based on AzureAD and Epitech Intranet groups. + +Enjoy ;) + +### Build + +``` +docker build -t samber/epitech-oauth2-proxy:4.0.0 . +``` + +### Setup + +1- Register a new webapp here => https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app + +2- Copy Tenant ID + Client ID + +3- Navigate to the "Certificates & secrets" page and create a new "client secret". + +4- Navigate to "Authentication" page and set the redirect url to `http://localhost:80/oauth2/callback` + +5- Navigate to "Manifest" page and set `groupMembershipClaims` to `All`. + +``` +docker run -d -p 80:80 -p 443:443 \ + samber/epitech-oauth2-proxy \ + -upstream=http://very-private-webapp:80 \ + -http-address=0.0.0.0:80 \ + -redirect-url=http://localhost:80/oauth2/callback \ + -scope='profile User.Read' \ + -email-domain=* \ + -cookie-domain=localhost \ + -cookie-secure=false \ + -cookie-secret=somerandomstring1234567890 \ + -provider=epitech \ + -azure-tenant ******************************** \ + -client-id ****************************** \ + -client-secret '****************************' \ + -epitech-group adm -epitech-group dpr -epitech-group dpra -epitech-group ape \ + -epitech-auth-token auth-*********************** +``` + +⚠️ For prod environment, remove the `-cookie-secure=false` argument ;) + ## Installation 1. Choose how to deploy: diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 934baab944..768a9809ed 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -6,8 +6,8 @@ GEM minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) - addressable (2.5.2) - public_suffix (>= 2.0.2, < 4.0) + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) coffee-script (2.4.1) coffee-script-source execjs @@ -16,8 +16,8 @@ GEM commonmarker (0.17.13) ruby-enum (~> 0.5) concurrent-ruby (1.1.4) - dnsruby (1.61.2) - addressable (~> 2.5) + dnsruby (1.61.9) + simpleidn (~> 0.1) em-websocket (0.5.1) eventmachine (>= 0.12.9) http_parser.rb (~> 0.6.0) @@ -210,7 +210,8 @@ GEM multipart-post (2.0.0) nokogiri (1.10.4) mini_portile2 (~> 2.4.0) - octokit (4.13.0) + octokit (4.22.0) + faraday (>= 0.9) sawyer (~> 0.8.0, >= 0.5.3) pathutil (0.16.2) forwardable-extended (~> 2.6) @@ -230,9 +231,11 @@ GEM sass-listen (4.0.0) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - sawyer (0.8.1) - addressable (>= 2.3.5, < 2.6) - faraday (~> 0.8, < 1.0) + sawyer (0.8.2) + addressable (>= 2.3.5) + faraday (> 0.8, < 2.0) + simpleidn (0.2.1) + unf (~> 0.1.4) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) thread_safe (0.3.6) @@ -240,6 +243,9 @@ GEM ethon (>= 0.9.0) tzinfo (1.2.5) thread_safe (~> 0.1) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.2) unicode-display_width (1.4.1) PLATFORMS diff --git a/main.go b/main.go index a9f1e4a081..3830b6dc09 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ func main() { skipAuthRegex := StringArray{} jwtIssuers := StringArray{} googleGroups := StringArray{} + epitechGroups := StringArray{} // CUSTOM EPITECH redisSentinelConnectionURLs := StringArray{} config := flagSet.String("config", "", "path to config file") @@ -129,6 +130,12 @@ func main() { flagSet.String("pubjwk-url", "", "JWK pubkey access endpoint: required by login.gov") flagSet.Bool("gcp-healthchecks", false, "Enable GCP/GKE healthcheck endpoints") + /* + * CUSTOM EPITECH + */ + flagSet.String("epitech-auth-token", "", "Auto-login token (format: auth-*****************).") + flagSet.Var(&epitechGroups, "epitech-group", "restrict logins to members of this Epitech Intranet group (may be given multiple times).") + flagSet.Parse(os.Args[1:]) if *showVersion { diff --git a/options.go b/options.go index 706f6d5ee1..a5d1fd9ce6 100644 --- a/options.go +++ b/options.go @@ -128,6 +128,12 @@ type Options struct { signatureData *SignatureData oidcVerifier *oidc.IDTokenVerifier jwtBearerVerifiers []*oidc.IDTokenVerifier + + /* + * CUSTOM EPITECH + */ + EpitechAuthToken string `flag:"epitech-auth-token" cfg:"epitech_auth_token" env:"OAUTH2_PROXY_EPITECH_AUTH_TOKEN"` + EpitechGroups []string `flag:"epitech-group" cfg:"epitech_group" env:"OAUTH2_PROXY_EPITECH_GROUPS"` } // SignatureData holds hmacauth signature hash and key @@ -394,6 +400,8 @@ func parseProviderInfo(o *Options, msgs []string) []string { o.provider = providers.New(o.Provider, p) switch p := o.provider.(type) { + case *providers.EpitechProvider: + p.Configure(o.AzureTenant, o.EpitechAuthToken, o.EpitechGroups) case *providers.AzureProvider: p.Configure(o.AzureTenant) case *providers.GitHubProvider: diff --git a/pkg/http_cache/cache.go b/pkg/http_cache/cache.go new file mode 100644 index 0000000000..b54f9bd3cd --- /dev/null +++ b/pkg/http_cache/cache.go @@ -0,0 +1,109 @@ +package http_cache + +import ( + "bufio" + "bytes" + "errors" + "net/http" + "net/http/httputil" + "sync" + "time" +) + +func NewCacheTransport(originalTransport http.RoundTripper, seconds int) *CacheTransport { + transport := &CacheTransport{ + data: make(map[string]string), + originalTransport: originalTransport, + } + + cacheClearJob(seconds, transport) + + return transport +} + +func cacheClearJob(seconds int, transport *CacheTransport) { + + interval := time.Duration(seconds) * time.Second + ticker := time.NewTicker(interval) + + go func() { + for { + + select { + case <-ticker.C: + transport.Clear() + } + } + }() +} + +func cacheKey(r *http.Request) string { + return r.URL.String() +} + +type CacheTransport struct { + data map[string]string + mu sync.RWMutex + originalTransport http.RoundTripper +} + +func (c *CacheTransport) Set(r *http.Request, value string) { + c.mu.Lock() + defer c.mu.Unlock() + c.data[cacheKey(r)] = value +} + +func (c *CacheTransport) Get(r *http.Request) (string, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + if val, ok := c.data[cacheKey(r)]; ok { + return val, nil + } + + return "", errors.New("key not found in cache") +} + +// Here is the main functionality +func (c *CacheTransport) RoundTrip(r *http.Request) (*http.Response, error) { + + // Check if we have the response cached.. + // If yes, we don't have to hit the server + // We just return it as is from the cache store. + if val, err := c.Get(r); err == nil { + return cachedResponse([]byte(val), r) + } + + // Ok, we don't have the response cached, the store was probably cleared. + // Make the request to the server. + resp, err := c.originalTransport.RoundTrip(r) + + if err != nil { + return nil, err + } + + // Get the body of the response so we can save it in the cache for the next request. + buf, err := httputil.DumpResponse(resp, true) + + if err != nil { + return nil, err + } + + // Saving it to the cache store + c.Set(r, string(buf)) + + return resp, nil +} + +func (c *CacheTransport) Clear() error { + c.mu.Lock() + defer c.mu.Unlock() + + c.data = make(map[string]string) + return nil +} + +func cachedResponse(b []byte, r *http.Request) (*http.Response, error) { + buf := bytes.NewBuffer(b) + return http.ReadResponse(bufio.NewReader(buf), r) +} diff --git a/providers/epitech.go b/providers/epitech.go new file mode 100644 index 0000000000..5024ce4e4f --- /dev/null +++ b/providers/epitech.go @@ -0,0 +1,181 @@ +package providers + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/pusher/oauth2_proxy/pkg/apis/sessions" + "github.com/pusher/oauth2_proxy/pkg/http_cache" +) + +// EpitechProvider represents an Epitech Intranet based Identity Provider +type EpitechProvider struct { + *AzureProvider + AuthToken string + + // GroupValidator is a function that determines if the passed email is in + // the configured Epitech group. + GroupValidator func(string) bool + + client *http.Client +} + +type epitechGroupMember struct { + Type string `json:"type"` + Login string `json:"login"` + Slug string `json:"slug"` + Location string `json:"location"` + Title string `json:"title"` + Close bool `json:"close"` +} + +// NewEpitechProvider initiates a new EpitechProvider +func NewEpitechProvider(p *ProviderData) *EpitechProvider { + provider := &EpitechProvider{ + AzureProvider: NewAzureProvider(p), + + // Set a default GroupValidator to just always return valid (true), it will + // be overwritten if we configured a Epitech group restriction. + GroupValidator: func(email string) bool { + return true + }, + + //Create a custom client so we can make use of our RoundTripper + //If you make use of http.Get(), the default http client located at http.DefaultClient is used instead + //Since we have special needs, we have to make use of our own http.RoundTripper implementation + client: &http.Client{ + Transport: http_cache.NewCacheTransport(http.DefaultTransport, 60), + Timeout: time.Second * 5, + }, + } + + provider.ProviderName = "Epitech" + + return provider +} + +// Configure defaults the EpitechProvider configuration options +func (p *EpitechProvider) Configure(tenant string, authToken string, groups []string) { + p.AzureProvider.Configure(tenant) + p.AuthToken = authToken + + if len(groups) > 0 { + p.GroupValidator = func(email string) bool { + return p.verifyGroupMembership(groups, authToken, email) + } + } +} + +// GetEmailAddress returns the Account email address +func (p *EpitechProvider) GetEmailAddress(s *sessions.SessionState) (string, error) { + return p.AzureProvider.GetEmailAddress(s) +} + +// GetUserName returns the Account email address +func (p *EpitechProvider) GetUserName(s *sessions.SessionState) (string, error) { + return p.AzureProvider.GetEmailAddress(s) +} + +func (p *EpitechProvider) Redeem(redirectURL, code string) (s *sessions.SessionState, err error) { + s, err = p.AzureProvider.Redeem(redirectURL, code) + if err != nil { + return + } + + // commented for now (probably too much data for X-Forwarded-User header) + // email, err := p.GetEmailAddress(s) + // if err != nil { + // return nil, err + // } + // rawUser, err := p.getEpitechUser(email, p.AuthToken) + // if err != nil { + // return nil, err + // } + // s.User = rawUser + + return +} + +// ValidateGroup validates that the provided email exists in the configured Epitech +// group(s). +func (p *EpitechProvider) ValidateGroup(email string) bool { + return p.GroupValidator(email) +} + +func (p *EpitechProvider) verifyGroupMembership(groups []string, authToken string, email string) bool { + fmt.Println("Checking Epitech group membership for " + email) + + for _, group := range groups { + members, err := p.getEpitechGroupMembers(group, authToken) + if err != nil { + fmt.Println(err.Error()) + continue + } + + for _, member := range members { + if member.Slug == email || member.Login == email { + return true + } + } + } + + return false +} + +func (p *EpitechProvider) getEpitechGroupMembers(groupName string, authToken string) ([]epitechGroupMember, error) { + path := fmt.Sprintf("https://intra.epitech.eu/%s/group/%s/member?format=json", authToken, groupName) + + var req *http.Request + req, err := http.NewRequest("GET", path, nil) + if err != nil { + return []epitechGroupMember{}, err + } + + resp, err := p.client.Do(req) + if err != nil { + return []epitechGroupMember{}, err + } + + var body []byte + body, err = ioutil.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != 200 { + return []epitechGroupMember{}, err + } + + var members []epitechGroupMember + err = json.Unmarshal([]byte(body), &members) + if err != nil { + return nil, err + } + + return members, nil +} + +func (p *EpitechProvider) getEpitechUser(email string, authToken string) (string, error) { + path := fmt.Sprintf("https://intra.epitech.eu/%s/user/%s/?format=json", authToken, email) + + var req *http.Request + req, err := http.NewRequest("GET", path, nil) + if err != nil { + return "", err + } + + resp, err := p.client.Do(req) + if err != nil { + return "", err + } + + var body []byte + body, err = ioutil.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != 200 { + return "", err + } + return string(body), nil +} diff --git a/providers/providers.go b/providers/providers.go index 276fab62db..6f924fd856 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -38,6 +38,11 @@ func New(provider string, p *ProviderData) Provider { return NewLoginGovProvider(p) case "bitbucket": return NewBitbucketProvider(p) + /* + * CUSTOM EPITECH + */ + case "epitech": + return NewEpitechProvider(p) default: return NewGoogleProvider(p) }