outposts/proxyv2 (#1365)

* outposts/proxyv2: initial commit

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

add rs256

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

more stuff

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

add forward auth an sign_out

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

match cookie name

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

re-add support for rs256 for backwards compat

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

add error handler

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

ensure unique user-agent is used

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

set cookie duration based on id_token expiry

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

build proxy v2

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

add ssl

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

add basic auth and custom header support

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

add application cert loading

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

implement whitelist

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

add redis

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

migrate embedded outpost to v2

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

remove old proxy

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

providers/proxy: make token expiration configurable

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

add metrics

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

fix tests

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* providers/proxy: only allow one redirect URI

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* fix docker build for proxy

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* remove default port offset

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add AUTHENTIK_HOST_BROWSER

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* tests: fix e2e/integration tests not using proper tags

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* remove references of old port

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* fix user_attributes not being loaded correctly

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* cleanup dependencies

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* cleanup

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens L
2021-09-08 20:04:56 +02:00
committed by GitHub
parent 27508dd1f0
commit 3c1b70c355
75 changed files with 1368 additions and 1665 deletions

View File

@ -0,0 +1,208 @@
package application
import (
"context"
"crypto/tls"
"encoding/gob"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/coreos/go-oidc"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/prometheus/client_golang/prometheus"
log "github.com/sirupsen/logrus"
"goauthentik.io/api"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/proxyv2/constants"
"goauthentik.io/internal/outpost/proxyv2/hs256"
"goauthentik.io/internal/outpost/proxyv2/metrics"
"goauthentik.io/internal/utils/web"
"golang.org/x/oauth2"
)
type Application struct {
Host string
Cert *tls.Certificate
UnauthenticatedRegex []*regexp.Regexp
oauthConfig oauth2.Config
tokenVerifier *oidc.IDTokenVerifier
sessions sessions.Store
proxyConfig api.ProxyOutpostConfig
httpClient *http.Client
log *log.Entry
mux *mux.Router
}
func akProviderToEndpoint(p api.ProxyOutpostConfig) oauth2.Endpoint {
authUrl := p.OidcConfiguration.AuthorizationEndpoint
if browserHost, found := os.LookupEnv("AUTHENTIK_HOST_BROWSER"); found {
host := os.Getenv("AUTHENTIK_HOST")
authUrl = strings.ReplaceAll(authUrl, host, browserHost)
}
return oauth2.Endpoint{
AuthURL: authUrl,
TokenURL: p.OidcConfiguration.TokenEndpoint,
AuthStyle: oauth2.AuthStyleInParams,
}
}
func NewApplication(p api.ProxyOutpostConfig, c *http.Client, cs *ak.CryptoStore) *Application {
gob.Register(Claims{})
externalHost, err := url.Parse(p.ExternalHost)
if err != nil {
log.WithError(err).Warning("Failed to parse URL, skipping provider")
}
// Support for RS256, new proxy providers will use HS256 but old ones
// might not, and this makes testing easier
var ks oidc.KeySet
if contains(p.OidcConfiguration.IdTokenSigningAlgValuesSupported, "HS256") {
ks = hs256.NewKeySet(*p.ClientSecret)
} else {
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, c)
oidc.NewRemoteKeySet(ctx, p.OidcConfiguration.JwksUri)
}
var verifier = oidc.NewVerifier(p.OidcConfiguration.Issuer, ks, &oidc.Config{
ClientID: *p.ClientId,
SupportedSigningAlgs: []string{"HS256"},
})
// Configure an OpenID Connect aware OAuth2 client.
oauth2Config := oauth2.Config{
ClientID: *p.ClientId,
ClientSecret: *p.ClientSecret,
RedirectURL: fmt.Sprintf("%s/akprox/callback", p.ExternalHost),
Endpoint: akProviderToEndpoint(p),
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "ak_proxy"},
}
mux := mux.NewRouter()
a := &Application{
Host: externalHost.Host,
log: log.WithField("logger", "authentik.outpost.proxy.bundle").WithField("provider", p.Name),
oauthConfig: oauth2Config,
tokenVerifier: verifier,
sessions: GetStore(p),
proxyConfig: p,
httpClient: c,
mux: mux,
}
muxLogger := log.WithField("logger", "authentik.outpost.proxyv2.application").WithField("name", p.Name)
mux.Use(web.NewLoggingHandler(muxLogger, func(l *log.Entry, r *http.Request) *log.Entry {
s, err := a.sessions.Get(r, constants.SeesionName)
if err != nil {
return l
}
claims, ok := s.Values[constants.SessionClaims]
if claims == nil || !ok {
return l
}
c, ok := claims.(Claims)
if !ok {
return l
}
return l.WithField("request_username", c.Email)
}))
mux.Use(func(inner http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
c, _ := a.getClaims(r)
user := ""
if c != nil {
user = c.Email
}
before := time.Now()
inner.ServeHTTP(rw, r)
after := time.Since(before)
metrics.Requests.With(prometheus.Labels{
"type": "app",
"scheme": r.URL.Scheme,
"method": r.Method,
"path": r.URL.Path,
"host": web.GetHost(r),
"user": user,
}).Observe(float64(after))
})
})
// Support /start and /sign_in for backwards compatibility
mux.HandleFunc("/akprox/start", a.handleRedirect)
mux.HandleFunc("/akprox/sign_in", a.handleRedirect)
mux.HandleFunc("/akprox/callback", a.handleCallback)
mux.HandleFunc("/akprox/sign_out", a.handleSignOut)
switch *p.Mode {
case api.PROXYMODE_PROXY:
err = a.configureProxy()
case api.PROXYMODE_FORWARD_SINGLE:
fallthrough
case api.PROXYMODE_FORWARD_DOMAIN:
err = a.configureForward()
}
if err != nil {
a.log.WithError(err).Warning("failed to configure mode")
}
if kp := p.Certificate.Get(); kp != nil {
err := cs.AddKeypair(*kp)
if err != nil {
a.log.WithError(err).Warning("Failed to initially fetch certificate")
}
a.Cert = cs.Get(*kp)
}
if *p.SkipPathRegex != "" {
a.UnauthenticatedRegex = make([]*regexp.Regexp, 0)
for _, regex := range strings.Split(*p.SkipPathRegex, "\n") {
re, err := regexp.Compile(regex)
if err != nil {
// TODO: maybe create event for this?
a.log.WithError(err).Warning("failed to compile regex")
} else {
a.UnauthenticatedRegex = append(a.UnauthenticatedRegex, re)
}
}
}
return a
}
func (a *Application) IsAllowlisted(r *http.Request) bool {
for _, u := range a.UnauthenticatedRegex {
if u.MatchString(r.URL.Path) {
return true
}
}
return false
}
func (a *Application) Mode() api.ProxyMode {
return *a.proxyConfig.Mode
}
func (a *Application) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
a.mux.ServeHTTP(rw, r)
}
func (a *Application) handleSignOut(rw http.ResponseWriter, r *http.Request) {
// TODO: Token revocation
s, err := a.sessions.Get(r, constants.SeesionName)
if err != nil {
http.Redirect(rw, r, a.proxyConfig.OidcConfiguration.EndSessionEndpoint, http.StatusFound)
return
}
s.Options.MaxAge = -1
err = s.Save(r, rw)
if err != nil {
http.Redirect(rw, r, a.proxyConfig.OidcConfiguration.EndSessionEndpoint, http.StatusFound)
return
}
http.Redirect(rw, r, a.proxyConfig.OidcConfiguration.EndSessionEndpoint, http.StatusFound)
}

View File

@ -0,0 +1,16 @@
package application
type ProxyClaims struct {
UserAttributes map[string]interface{} `json:"user_attributes"`
}
type Claims struct {
Sub string `json:"sub"`
Exp int `json:"exp"`
Email string `json:"email"`
Verified bool `json:"email_verified"`
Proxy ProxyClaims `json:"ak_proxy"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Groups []string `json:"groups"`
}

View File

@ -0,0 +1,29 @@
package application
import (
"html/template"
"net/http"
log "github.com/sirupsen/logrus"
)
// NewProxyErrorHandler creates a ProxyErrorHandler using the template given.
func NewProxyErrorHandler(errorTemplate *template.Template) func(http.ResponseWriter, *http.Request, error) {
return func(rw http.ResponseWriter, req *http.Request, proxyErr error) {
log.Errorf("Error proxying to upstream server: %v", proxyErr)
rw.WriteHeader(http.StatusBadGateway)
data := struct {
Title string
Message string
ProxyPrefix string
}{
Title: "Bad Gateway",
Message: "Error proxying to upstream server",
ProxyPrefix: "/akprox",
}
err := errorTemplate.Execute(rw, data)
if err != nil {
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
}
}
}

View File

@ -0,0 +1,53 @@
package application
import (
"encoding/base64"
"fmt"
"net/http"
"strings"
)
func (a *Application) addHeaders(r *http.Request, c *Claims) {
// https://goauthentik.io/docs/providers/proxy/proxy
r.Header.Set("X-Auth-Username", c.PreferredUsername)
r.Header.Set("X-Auth-Groups", strings.Join(c.Groups, "|"))
r.Header.Set("X-Forwarded-Email", c.Email)
r.Header.Set("X-Forwarded-Preferred-Username", c.PreferredUsername)
r.Header.Set("X-Forwarded-User", c.Sub)
userAttributes := c.Proxy.UserAttributes
// Attempt to set basic auth based on user's attributes
if *a.proxyConfig.BasicAuthEnabled {
var ok bool
var password string
if password, ok = userAttributes[*a.proxyConfig.BasicAuthPasswordAttribute].(string); !ok {
password = ""
}
// Check if we should use email or a custom attribute as username
var username string
if username, ok = userAttributes[*a.proxyConfig.BasicAuthUserAttribute].(string); !ok {
username = c.Email
}
authVal := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
a.log.WithField("username", username).Trace("setting http basic auth")
r.Header["Authorization"] = []string{fmt.Sprintf("Basic %s", authVal)}
}
// Check if user has additional headers set that we should sent
if additionalHeaders, ok := userAttributes["additionalHeaders"].(map[string]interface{}); ok {
a.log.WithField("headers", additionalHeaders).Trace("setting additional headers")
if additionalHeaders == nil {
return
}
for key, value := range additionalHeaders {
r.Header.Set(key, toString(value))
}
}
}
func copyHeadersToResponse(rw http.ResponseWriter, r *http.Request) {
for headerKey, headers := range r.Header {
for _, value := range headers {
rw.Header().Set(headerKey, value)
}
}
}

View File

@ -0,0 +1,77 @@
package application
import (
"fmt"
"net/http"
"net/url"
"goauthentik.io/api"
"goauthentik.io/internal/utils/web"
)
func (a *Application) configureForward() error {
a.mux.HandleFunc("/akprox/auth", func(rw http.ResponseWriter, r *http.Request) {
if _, ok := r.URL.Query()["traefik"]; ok {
a.forwardHandleTraefik(rw, r)
return
}
a.forwardHandleNginx(rw, r)
})
a.mux.HandleFunc("/akprox/auth/traefik", a.forwardHandleTraefik)
a.mux.HandleFunc("/akprox/auth/nginx", a.forwardHandleNginx)
return nil
}
func (a *Application) forwardHandleTraefik(rw http.ResponseWriter, r *http.Request) {
claims, err := a.getClaims(r)
if claims != nil && err == nil {
a.addHeaders(r, claims)
copyHeadersToResponse(rw, r)
return
} else if claims == nil && a.IsAllowlisted(r) {
a.log.Trace("path can be accessed without authentication")
return
}
host := ""
// Optional suffix, which is appended to the URL
suffix := ""
if *a.proxyConfig.Mode == api.PROXYMODE_FORWARD_SINGLE {
host = web.GetHost(r)
} else if *a.proxyConfig.Mode == api.PROXYMODE_FORWARD_DOMAIN {
host = a.proxyConfig.ExternalHost
// set the ?rd flag to the current URL we have, since we redirect
// to a (possibly) different domain, but we want to be redirected back
// to the application
v := url.Values{
// see https://doc.traefik.io/traefik/middlewares/forwardauth/
// X-Forwarded-Uri is only the path, so we need to build the entire URL
"rd": []string{fmt.Sprintf(
"%s://%s%s",
r.Header.Get("X-Forwarded-Proto"),
r.Header.Get("X-Forwarded-Host"),
r.Header.Get("X-Forwarded-Uri"),
)},
}
suffix = fmt.Sprintf("?%s", v.Encode())
}
proto := r.Header.Get("X-Forwarded-Proto")
if proto != "" {
proto = proto + ":"
}
rdFinal := fmt.Sprintf("%s//%s%s%s", proto, host, "/akprox/start", suffix)
a.log.WithField("url", rdFinal).Debug("Redirecting to login")
http.Redirect(rw, r, rdFinal, http.StatusTemporaryRedirect)
}
func (a *Application) forwardHandleNginx(rw http.ResponseWriter, r *http.Request) {
claims, err := a.getClaims(r)
if claims != nil && err == nil {
a.addHeaders(r, claims)
copyHeadersToResponse(rw, r)
return
} else if claims == nil && a.IsAllowlisted(r) {
a.log.Trace("path can be accessed without authentication")
return
}
http.Error(rw, "unauthorized request", http.StatusUnauthorized)
}

View File

@ -0,0 +1,63 @@
package application
import (
"net/http"
"net/http/httputil"
"net/url"
"time"
"github.com/prometheus/client_golang/prometheus"
"goauthentik.io/internal/outpost/proxyv2/metrics"
"goauthentik.io/internal/outpost/proxyv2/templates"
"goauthentik.io/internal/utils/web"
)
func (a *Application) configureProxy() error {
// Reverse proxy to the application server
u, err := url.Parse(*a.proxyConfig.InternalHost)
if err != nil {
return err
}
rp := &httputil.ReverseProxy{Director: a.proxyModifyRequest(u)}
rp.ErrorHandler = NewProxyErrorHandler(templates.GetTemplates())
rp.ModifyResponse = a.proxyModifyResponse
a.mux.PathPrefix("/").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
claims, err := a.getClaims(r)
if claims == nil && a.IsAllowlisted(r) {
a.log.Trace("path can be accessed without authentication")
} else if claims == nil && err != nil {
a.redirectToStart(rw, r)
return
} else {
a.addHeaders(r, claims)
}
before := time.Now()
rp.ServeHTTP(rw, r)
after := time.Since(before)
user := ""
if claims != nil {
user = claims.Email
}
metrics.UpstreamTiming.With(prometheus.Labels{
"upstream_host": u.String(),
"scheme": r.URL.Scheme,
"method": r.Method,
"path": r.URL.Path,
"host": web.GetHost(r),
"user": user,
}).Observe(float64(after))
})
return nil
}
func (a *Application) proxyModifyRequest(u *url.URL) func(req *http.Request) {
return func(req *http.Request) {
req.URL.Scheme = u.Scheme
req.URL.Host = u.Host
}
}
func (a *Application) proxyModifyResponse(res *http.Response) error {
return nil
}

View File

@ -0,0 +1,54 @@
package application
import (
"encoding/base64"
"net/http"
"github.com/gorilla/securecookie"
"goauthentik.io/internal/outpost/proxyv2/constants"
)
func (a *Application) handleRedirect(rw http.ResponseWriter, r *http.Request) {
state := base64.RawStdEncoding.EncodeToString(securecookie.GenerateRandomKey(32))
s, _ := a.sessions.Get(r, constants.SeesionName)
s.Values[constants.SessionOAuthState] = state
err := s.Save(r, rw)
if err != nil {
a.log.WithError(err).Warning("failed to save session")
}
http.Redirect(rw, r, a.oauthConfig.AuthCodeURL(state), http.StatusFound)
}
func (a *Application) handleCallback(rw http.ResponseWriter, r *http.Request) {
s, _ := a.sessions.Get(r, constants.SeesionName)
state, ok := s.Values[constants.SessionOAuthState]
if !ok {
a.log.Warning("No state saved in session")
http.Redirect(rw, r, a.proxyConfig.ExternalHost, http.StatusFound)
return
}
claims, err := a.redeemCallback(r, state.(string))
if err != nil {
a.log.WithError(err).Warning("failed to redeem code")
rw.WriteHeader(400)
// To prevent the user from just refreshing and cause more errors, delete
// the state from the session
delete(s.Values, constants.SessionOAuthState)
err := s.Save(r, rw)
if err != nil {
a.log.WithError(err).Warning("failed to save session")
rw.WriteHeader(400)
return
}
return
}
s.Options.MaxAge = claims.Exp / 1000
s.Values[constants.SessionClaims] = &claims
err = s.Save(r, rw)
if err != nil {
a.log.WithError(err).Warning("failed to save session")
rw.WriteHeader(400)
return
}
http.Redirect(rw, r, a.proxyConfig.ExternalHost, http.StatusFound)
}

View File

@ -0,0 +1,49 @@
package application
import (
"context"
"fmt"
"net/http"
"golang.org/x/oauth2"
)
func (a *Application) redeemCallback(r *http.Request, shouldState string) (*Claims, error) {
state := r.URL.Query().Get("state")
if state == "" || state != shouldState {
return nil, fmt.Errorf("blank/invalid state")
}
code := r.URL.Query().Get("code")
if code == "" {
return nil, fmt.Errorf("blank code")
}
ctx := context.WithValue(r.Context(), oauth2.HTTPClient, a.httpClient)
// Verify state and errors.
oauth2Token, err := a.oauthConfig.Exchange(ctx, code)
if err != nil {
return nil, err
}
// Extract the ID Token from OAuth2 token.
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
return nil, fmt.Errorf("missing id_token")
}
a.log.WithField("id_token", rawIDToken).Trace("id_token")
// Parse and verify ID Token payload.
idToken, err := a.tokenVerifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, err
}
// Extract custom claims
var claims *Claims
if err := idToken.Claims(&claims); err != nil {
return nil, err
}
return claims, nil
}

View File

@ -0,0 +1,28 @@
package application
import (
"fmt"
"strconv"
"github.com/gorilla/sessions"
"goauthentik.io/api"
"goauthentik.io/internal/config"
"gopkg.in/boj/redistore.v1"
)
func GetStore(p api.ProxyOutpostConfig) sessions.Store {
var store sessions.Store
if config.G.Redis.Host != "" {
rs, err := redistore.NewRediStoreWithDB(10, "tcp", fmt.Sprintf("%s:%d", config.G.Redis.Host, config.G.Redis.Port), config.G.Redis.Password, strconv.Itoa(config.G.Redis.OutpostSessionDB))
if err != nil {
panic(err)
}
rs.Options.Domain = *p.CookieDomain
store = rs
} else {
cs := sessions.NewCookieStore([]byte(*p.CookieSecret))
cs.Options.Domain = *p.CookieDomain
store = cs
}
return store
}

View File

@ -0,0 +1,56 @@
package application
import (
"fmt"
"net/http"
"strconv"
"goauthentik.io/internal/outpost/proxyv2/constants"
)
func (a *Application) redirectToStart(rw http.ResponseWriter, r *http.Request) {
authUrl := fmt.Sprintf("%s/akprox/start", a.proxyConfig.ExternalHost)
http.Redirect(rw, r, authUrl, http.StatusFound)
}
// getClaims Get claims which are currently in session
// Returns an error if the session can't be loaded or the claims can't be parsed/type-cast
func (a *Application) getClaims(r *http.Request) (*Claims, error) {
s, err := a.sessions.Get(r, constants.SeesionName)
if err != nil {
// err == user has no session/session is not valid, reject
return nil, fmt.Errorf("invalid session")
}
claims, ok := s.Values[constants.SessionClaims]
if claims == nil || !ok {
// no claims saved, reject
return nil, fmt.Errorf("invalid session")
}
c, ok := claims.(Claims)
if !ok {
return nil, fmt.Errorf("invalid session")
}
return &c, nil
}
func contains(s []string, e string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
// toString Generic to string function, currently supports actual strings and integers
func toString(in interface{}) string {
switch v := in.(type) {
case string:
return v
case *string:
return *v
case int:
return strconv.Itoa(v)
}
return ""
}