Skip to content

Commit

Permalink
Merge pull request #54 from mesosphere/master-new
Browse files Browse the repository at this point in the history
chore: fix master and v3 branches
  • Loading branch information
joejulian authored Feb 8, 2022
2 parents 67f8234 + 6a275ec commit 732b480
Show file tree
Hide file tree
Showing 19 changed files with 916 additions and 345 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ This is a partial rewrite to support generic OIDC Providers that provide [OpenID

[`noelcatt/traefik-forward-auth`](https://github.com/noelcatt/traefik-forward-auth) and [`funkypenguin/traefik-forward-auth`](https://github.com/funkypenguin/traefik-forward-auth) also made [`thomseddon/traefik-forward-auth`](https://github.com/thomseddon/traefik-forward-auth) apply to generic OIDC, but they are now based on an older version which does not support rules and also require the UserInfo endpoint to be supported.

This version optionally implements RBAC within Kuberbetes by using `ClusterRole` and `ClusterRoleBinding`. It extends from the original Kubernetes usage as it also allows specifying full URLs (including a scheme and domain) within `nonResourceURLs` attribute of `ClusterRole`. And unlike the original behavior, `*` wildcard character matches within one path component only. There is a special globstar `**` to match within multiple paths (inspired by Bash, Python and JS libraries).

The raw id-token received from OIDC provider can optionally be passed upstream via a custom header.

## Differences to the original

The instructions for [`thomseddon/traefik-forward-auth`](https://github.com/thomseddon/traefik-forward-auth) are useful, keeping in mind that this version:
Expand All @@ -19,3 +23,10 @@ The instructions for [`thomseddon/traefik-forward-auth`](https://github.com/thom
- Returns 401 rather than redirect to OIDC Login if an unauthenticated request is not for HTML (e.g. AJAX calls, images).
- Sends a username cookie as well
- If `auth-host` is set and `cookie-domains` is not set, traefik-forward-auth will redirect any requests using other hostnames to `auth-host`. Set `auth-host` to the OIDC redirect host to ensure that use of the IP or other DNS names will be redirected and get a suitable cookie.

## Upgrading from 2.x version to 3.0 (Breaking Changes):

- config `session-key` (`SESSION_KEY` env) is now called `encryption-key` (`ENCRYPTION_KEY` env) and is `REQUIRED`
- config `groups-session-name` (`GROUPS_SESSION_NAME`) is deprecated as both email and groups are part of the single cookie `cookie-name` (`COOKIE_NAME` env)
- character `*` in existing RBAC rules now works within one path component only, so a single `*` has to be replaced with `**` to match the previous behavior (whether to use `*` or `**` is up to the person writing those rules)

36 changes: 23 additions & 13 deletions cmd/main.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
package main

import (
"fmt"
"net/http"
"os"
"time"

"github.com/gorilla/sessions"
k8s "k8s.io/client-go/kubernetes"

"github.com/mesosphere/traefik-forward-auth/internal/api/storage/v1alpha1"
"github.com/mesosphere/traefik-forward-auth/internal/authentication"
"github.com/mesosphere/traefik-forward-auth/internal/configuration"
"github.com/mesosphere/traefik-forward-auth/internal/handlers"
kubernetes "github.com/mesosphere/traefik-forward-auth/internal/kubernetes"
logger "github.com/mesosphere/traefik-forward-auth/internal/log"
"github.com/mesosphere/traefik-forward-auth/internal/storage"
"github.com/mesosphere/traefik-forward-auth/internal/storage/cluster"
"net/http"
"os"
"time"

"github.com/gorilla/sessions"
logger "github.com/mesosphere/traefik-forward-auth/internal/log"
k8s "k8s.io/client-go/kubernetes"
)

// Main
func main() {
// Parse options
config := configuration.NewGlobalConfig(os.Args[1:])
config, err := configuration.NewConfig(os.Args[1:])
if err != nil {
fmt.Printf("%+v\n", err)
os.Exit(1)
}

// Setup logger
log := logger.NewDefaultLogger(config.LogLevel, config.LogFormat)
Expand All @@ -29,7 +35,9 @@ func main() {
config.Validate()

// Query the OIDC provider
config.SetOidcProvider()
if err := config.LoadOIDCProviderConfiguration(); err != nil {
log.Fatalln(err.Error())
}

authenticator := authentication.NewAuthenticator(config)
// Get clientset for Authorizers
Expand All @@ -45,7 +53,9 @@ func main() {
var userInfoStore v1alpha1.UserInfoInterface
if !config.EnableInClusterStorage {
// Prepare cookie session store (first key is for auth, the second one for encryption)
cookieStore := sessions.NewCookieStore(config.Secret, []byte(config.SessionKey))
hashKey := []byte(config.SecretString)
blockKey := []byte(config.EncryptionKeyString)
cookieStore := sessions.NewCookieStore(hashKey, blockKey)
cookieStore.Options.MaxAge = int(config.Lifetime / time.Second)
cookieStore.Options.HttpOnly = true
cookieStore.Options.Secure = !config.InsecureCookie
Expand All @@ -59,7 +69,7 @@ func main() {
userInfoStore = cluster.NewClusterStore(
clientset,
config.ClusterStoreNamespace,
string(config.Secret),
config.SecretString,
config.Lifetime,
time.Duration(config.ClusterStoreCacheTTL)*time.Second,
authenticator)
Expand All @@ -77,7 +87,7 @@ func main() {
http.HandleFunc("/", server.RootHandler)

// Start
log.Debugf("Starting with options: %s", config)
log.Info("Listening on :4181")
log.Debugf("starting with options: %s", config)
log.Info("listening on :4181")
log.Info(http.ListenAndServe(":4181", nil))
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
github.com/googleapis/gnostic v0.3.1 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/sessions v1.2.0
github.com/gorilla/securecookie v1.1.1
github.com/gravitational/trace v0.0.0-20190409171327-f30095ced5ff // indirect
github.com/jonboulle/clockwork v0.1.0 // indirect
github.com/json-iterator/go v1.1.8 // indirect
Expand Down
157 changes: 75 additions & 82 deletions internal/authentication/auth.go
Original file line number Diff line number Diff line change
@@ -1,69 +1,55 @@
package authentication

import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"

"github.com/gorilla/securecookie"

"github.com/mesosphere/traefik-forward-auth/internal/configuration"
)

type Authenticator struct {
config *configuration.Config
config *configuration.Config
secureCookie *securecookie.SecureCookie
}

func NewAuthenticator(config *configuration.Config) *Authenticator {
return &Authenticator{config}
}

// Request Validation
cookieMaxAge := config.CookieMaxAge()
hashKey := []byte(config.SecretString)
blockKey := []byte(config.EncryptionKeyString)

// Cookie = hash(secret, cookie domain, email, expires)|expires|email|groups
func (a *Authenticator) ValidateCookie(r *http.Request, c *http.Cookie) (string, error) {
parts := strings.Split(c.Value, "|")

if len(parts) != 3 {
return "", errors.New("invalid cookie format")
return &Authenticator{
config: config,
secureCookie: securecookie.New(hashKey, blockKey).MaxAge(cookieMaxAge),
}
}

mac, err := base64.URLEncoding.DecodeString(parts[0])
if err != nil {
return "", errors.New("unable to decode cookie mac")
}
type ID struct {
Email string
Token string
}

expectedSignature := a.cookieSignature(r, parts[2], parts[1])
expected, err := base64.URLEncoding.DecodeString(expectedSignature)
if err != nil {
return "", errors.New("unable to generate mac")
}
// Request Validation

// Valid token?
if !hmac.Equal(mac, expected) {
return "", errors.New("invalid cookie mac")
}
// ValidateCookie validates the ID cookie in the request
// IDCookie = hash(secret, cookie domain, email, expires)|expires|email|group
func (a *Authenticator) ValidateCookie(r *http.Request, c *http.Cookie) (*ID, error) {
var data ID

expires, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return "", errors.New("unable to parse cookie expiry")
if err := a.secureCookie.Decode(a.config.CookieName, c.Value, &data); err != nil {
return nil, err
}

// Has it expired?
if time.Unix(expires, 0).Before(time.Now()) {
return "", errors.New("cookie has expired")
}

// Looks valid
return parts[2], nil
return &data, nil
}

// Validate email
// ValidateEmail validates that the provided email ends with one of the configured Domains or is part of the configured Whitelist.
// Also returns true if there is no Whitelist and no Domains configured.
func (a *Authenticator) ValidateEmail(email string) bool {
if len(a.config.Whitelist) > 0 || len(a.config.Domains) > 0 {
for _, whitelist := range a.config.Whitelist {
Expand All @@ -83,17 +69,20 @@ func (a *Authenticator) ValidateEmail(email string) bool {
return true
}

// Get oauth redirect uri
func (a *Authenticator) RedirectUri(r *http.Request) string {
// ComposeRedirectURI generates oauth redirect uri to return to from the OAuth2 provider
func (a *Authenticator) ComposeRedirectURI(r *http.Request) string {
if use, _ := a.useAuthDomain(r); use {
proto := r.Header.Get("X-Forwarded-Proto")
return fmt.Sprintf("%s://%s%s", proto, a.config.AuthHost, a.config.Path)
scheme := r.Header.Get("X-Forwarded-Proto")
return fmt.Sprintf("%s://%s%s", scheme, a.config.AuthHost, a.config.Path)

}

return fmt.Sprintf("%s%s", redirectBase(r), a.config.Path)
return fmt.Sprintf("%s%s", getRequestSchemeHost(r), a.config.Path)
}

// Should we use auth host + what it is
// useAuthDomain decides whether the host of the forwarded request
// matches the configured AuthHost and whether we can configure cookies for the AuthHost
// If it does, the function returns true and the top-level domain from the config we can use
func (a *Authenticator) useAuthDomain(r *http.Request) (bool, string) {
if a.config.AuthHost == "" {
return false, ""
Expand All @@ -111,26 +100,35 @@ func (a *Authenticator) useAuthDomain(r *http.Request) (bool, string) {

// Cookie methods

// Create an auth cookie
func (a *Authenticator) MakeIDCookie(r *http.Request, email string) *http.Cookie {
expires := a.cookieExpiry()
mac := a.cookieSignature(r, email, fmt.Sprintf("%d", expires.Unix()))
value := fmt.Sprintf("%s|%d|%s", mac, expires.Unix(), email)
// MakeIDCookie creates an auth cookie
func (a *Authenticator) MakeIDCookie(r *http.Request, email string, token string) (*http.Cookie, error) {
expires := a.config.CookieExpiry()
data := &ID{
Email: email,
Token: token,
}

return &http.Cookie{
encoded, err := a.secureCookie.Encode(a.config.CookieName, data)
if err != nil {
return nil, err
}

cookie := &http.Cookie{
Name: a.config.CookieName,
Value: value,
Value: encoded,
Path: "/",
Domain: a.GetCookieDomain(r),
HttpOnly: true,
Secure: !a.config.InsecureCookie,
Expires: expires,
}

return cookie, nil
}

// Create a name cookie
// MakeNameCookie creates a name cookie
func (a *Authenticator) MakeNameCookie(r *http.Request, name string) *http.Cookie {
expires := a.cookieExpiry()
expires := a.config.CookieExpiry()

return &http.Cookie{
Name: a.config.UserCookieName,
Expand All @@ -143,7 +141,7 @@ func (a *Authenticator) MakeNameCookie(r *http.Request, name string) *http.Cooki
}
}

// Make a CSRF cookie (used during login only)
// MakeCSRFCookie creates a CSRF cookie (used during login only)
func (a *Authenticator) MakeCSRFCookie(r *http.Request, nonce string) *http.Cookie {
return &http.Cookie{
Name: a.config.CSRFCookieName,
Expand All @@ -152,11 +150,11 @@ func (a *Authenticator) MakeCSRFCookie(r *http.Request, nonce string) *http.Cook
Domain: a.csrfCookieDomain(r),
HttpOnly: true,
Secure: !a.config.InsecureCookie,
Expires: a.cookieExpiry(),
Expires: a.config.CookieExpiry(),
}
}

// Create a cookie to clear csrf cookie
// ClearCSRFCookie clears the csrf cookie
func (a *Authenticator) ClearCSRFCookie(r *http.Request) *http.Cookie {
return &http.Cookie{
Name: a.config.CSRFCookieName,
Expand All @@ -169,7 +167,7 @@ func (a *Authenticator) ClearCSRFCookie(r *http.Request) *http.Cookie {
}
}

// Validate the csrf cookie against state
// ValidateCSRFCookie validates the csrf cookie against state
func ValidateCSRFCookie(r *http.Request, c *http.Cookie) (bool, string, error) {
state := r.URL.Query().Get("state")

Expand All @@ -190,15 +188,16 @@ func ValidateCSRFCookie(r *http.Request, c *http.Cookie) (bool, string, error) {
return true, state[33:], nil
}

func Nonce() (error, string) {
// GenerateNonce generates a random nonce string
func GenerateNonce() (string, error) {
// Make nonce
nonce := make([]byte, 16)
_, err := rand.Read(nonce)
if err != nil {
return err, ""
return "", err
}

return nil, fmt.Sprintf("%x", nonce)
return fmt.Sprintf("%x", nonce), nil
}

// Cookie domain
Expand All @@ -224,7 +223,10 @@ func (a *Authenticator) csrfCookieDomain(r *http.Request) string {
return p[0]
}

// Return matching cookie domain if exists
// matchCookieDomains checks if the provided domain maches any domain configured in the CookieDomains list
// and returns the domain from the list it matched with.
// The match is either the direct equality of domain names or the input subdomain (e.g. "a.test.com") belongs under a configured top domain ("test.com").
// If the domain does not match CookieDomains, false is returned with the input domain as the second return value.
func (a *Authenticator) matchCookieDomains(domain string) (bool, string) {
// Remove port
p := strings.Split(domain, ":")
Expand All @@ -240,37 +242,28 @@ func (a *Authenticator) matchCookieDomains(domain string) (bool, string) {
return false, p[0]
}

// Create cookie hmac
func (a *Authenticator) cookieSignature(r *http.Request, email, expires string) string {
hash := hmac.New(sha256.New, a.config.Secret)
hash.Write([]byte(a.GetCookieDomain(r)))
hash.Write([]byte(email))
hash.Write([]byte(expires))
return base64.URLEncoding.EncodeToString(hash.Sum(nil))
}

// Get cookie expirary
func (a *Authenticator) cookieExpiry() time.Time {
return time.Now().Local().Add(a.config.Lifetime)
}

// Utility methods

// Get the redirect base
func redirectBase(r *http.Request) string {
// getRequestSchemeHost returns scheme://host part of the request
// Example output: "https://domain.com"
func getRequestSchemeHost(r *http.Request) string {
proto := r.Header.Get("X-Forwarded-Proto")
host := r.Header.Get("X-Forwarded-Host")

return fmt.Sprintf("%s://%s", proto, host)
}

func GetUriPath(r *http.Request) string {
// GetRequestURI returns the full request URI with query parameters.
// The path includes the prefix (if stripPrefix middleware was used).
// Example output: "/prefix/path?query=1"
func GetRequestURI(r *http.Request) string {
prefix := r.Header.Get("X-Forwarded-Prefix")
uri := r.Header.Get("X-Forwarded-Uri")
return fmt.Sprintf("%s/%s", strings.TrimRight(prefix, "/"), strings.TrimLeft(uri, "/"))
}

// // Return url
func ReturnUrl(r *http.Request) string {
return fmt.Sprintf("%s%s", redirectBase(r), GetUriPath(r))
// GetRequestURL returns full requst URL scheme://host/uri with query params
// Example output: "https://domain.com/prefix/path?query=1"
func GetRequestURL(r *http.Request) string {
return fmt.Sprintf("%s%s", getRequestSchemeHost(r), GetRequestURI(r))
}
Loading

0 comments on commit 732b480

Please sign in to comment.