From f8086c74922b53c21c72eecaa951b3b2072cfb53 Mon Sep 17 00:00:00 2001 From: memory Date: Tue, 8 May 2018 14:34:32 -0400 Subject: [PATCH] support using an identity-aware proxy for auth (#101) Rather than directly fetching and verifying OAuth assertions, assume that the app is running behind an authenticating proxy, and trust headers that are set by the proxy. - add config support for an "authbackend" directive, supporting either "oauth" or "proxy" as values; the "proxy" setting selects our new codepath - add initProxyAuth and proxyAuthMiddleware methods to the Handler struct - rename authMiddleWare to oAuthMiddleware in the Handler struct - construct a faked auth.JWTClaims object when in proxy mode - update Handler.handleAuthCheck() to return useful info in proxy mode - add a fallback user icon for proxy mode - implement check for proxy mode in index.js See for example and reference: https://cloud.google.com/iap/docs/identity-howto https://cloud.google.com/beyondcorp/ --- README.md | 4 +- build/config.yaml | 11 +++- handlers/auth.go | 89 ++++++++++++++++++++++++++-- handlers/handlers.go | 22 +++++-- static/public/images/proxy_user.png | Bin 0 -> 2859 bytes static/src/index.js | 39 +++++++++--- util/config.go | 33 +++++++---- 7 files changed, 163 insertions(+), 35 deletions(-) create mode 100644 static/public/images/proxy_user.png diff --git a/README.md b/README.md index 932e728..ccc79cf 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ - Visitor Counting - Expirable Links - URL deletion -- Authorization System via OAuth 2.0 (Google, GitHub and Microsoft) +- Multiple authorization strategies: + - Local authorization via OAuth 2.0 (Google, GitHub and Microsoft) + - Proxy authorization for running behind e.g. [Google IAP](https://cloud.google.com/iap/) - Easy [ShareX](https://github.com/ShareX/ShareX) integration - Dockerizable - Multiple supported storage backends diff --git a/build/config.yaml b/build/config.yaml index 1858867..4aceadf 100644 --- a/build/config.yaml +++ b/build/config.yaml @@ -6,12 +6,17 @@ RedisPassword: replace me # if using the redis backend, a conneciton password. DataDir: ./data # Contains: the database and the private key EnableDebugMode: true # Activates more detailed logging ShortedIDLength: 10 # Length of the random generated ID which is used for new shortened URLs -Google: +AuthBackend: oauth # Can be 'oauth' or 'proxy' +Google: # only relevant when using the oauth authbackend ClientID: replace me ClientSecret: replace me -GitHub: +GitHub: # only relevant when using the oauth authbackend ClientID: replace me ClientSecret: replace me -Microsoft: +Microsoft: # only relevant when using the oauth authbackend ClientID: replace me ClientSecret: 'replace me' +Proxy: # only relevant when using the proxy authbackend + RequireUserHeader: false # If true, will reject connections that do not have the UserHeader set + UserHeader: "X-Goog-Authenticated-User-ID" # pull the unique user ID from this header + DisplayNameHeader: "X-Goog-Authenticated-User-Email" # pull the display naem from this header diff --git a/handlers/auth.go b/handlers/auth.go index 6f1f3ec..2b7d20b 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "net/http" "github.com/mxschmitt/golang-url-shortener/handlers/auth" @@ -36,6 +37,14 @@ func (h *Handler) initOAuth() { h.engine.POST("/api/v1/auth/check", h.handleAuthCheck) } +// initProxyAuth intializes data structures for proxy authentication mode +func (h *Handler) initProxyAuth() { + h.engine.Use(sessions.Sessions("backend", sessions.NewCookieStore(util.GetPrivateKey()))) + h.providers = []string{} + h.providers = append(h.providers, "proxy") + h.engine.POST("/api/v1/auth/check", h.handleAuthCheck) +} + func (h *Handler) parseJWT(wt string) (*auth.JWTClaims, error) { token, err := jwt.ParseWithClaims(wt, &auth.JWTClaims{}, func(token *jwt.Token) (interface{}, error) { return util.GetPrivateKey(), nil @@ -49,7 +58,8 @@ func (h *Handler) parseJWT(wt string) (*auth.JWTClaims, error) { return token.Claims.(*auth.JWTClaims), nil } -func (h *Handler) authMiddleware(c *gin.Context) { +// oAuthMiddleware implements an auth layer that validates a JWT token +func (h *Handler) oAuthMiddleware(c *gin.Context) { authError := func() error { wt := c.GetHeader("Authorization") if wt == "" { @@ -72,25 +82,94 @@ func (h *Handler) authMiddleware(c *gin.Context) { c.Next() } +// proxyAuthMiddleware implements an auth layer that trusts (and +// optionally requires) header data from an identity-aware proxy +func (h *Handler) proxyAuthMiddleware(c *gin.Context) { + authError := func() error { + claims, err := h.fakeClaimsForProxy(c) + if err != nil { + return err + } + c.Set("user", claims) + return nil + }() + if authError != nil { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "authentication failed", + }) + logrus.Errorf("Authentication middleware check failed: %v\n", authError) + return + } + c.Next() +} + +// fakeClaimsForProxy returns a pointer to a auth.JWTClaims struct containing +// data pulled from headers inserted by an identity-aware proxy. +func (h *Handler) fakeClaimsForProxy(c *gin.Context) (*auth.JWTClaims, error) { + uid := c.GetHeader(util.GetConfig().Proxy.UserHeader) + logrus.Debugf("Got proxy uid '%s' from header '%s'", uid, util.GetConfig().Proxy.UserHeader) + if uid == "" { + logrus.Debugf("No proxy uid found!") + if util.GetConfig().Proxy.RequireUserHeader { + msg := fmt.Sprintf("Required authorization header not set: %s", util.GetConfig().Proxy.UserHeader) + logrus.Error(msg) + return nil, errors.New(msg) + } + logrus.Debugf("Setting uid to 'anonymous'") + uid = "anonymous" + } + // optionally pick a display name out of the headers as well; if we + // can't find it, just use the uid. + displayName := c.GetHeader(util.GetConfig().Proxy.DisplayNameHeader) + logrus.Debugf("Got proxy display name '%s' from header '%s'", displayName, util.GetConfig().Proxy.DisplayNameHeader) + if displayName == "" { + logrus.Debugf("Setting displayname to '%s'", uid) + displayName = uid + } + // it's not actually oauth but the naming convention is too + // deeply embedded in the code for it to be worth changing. + claims := &auth.JWTClaims{ + OAuthID: uid, + OAuthName: displayName, + OAuthPicture: "/images/proxy_user.png", + OAuthProvider: "proxy", + } + return claims, nil +} + func (h *Handler) handleAuthCheck(c *gin.Context) { var data struct { Token string `binding:"required"` } - if err := c.ShouldBind(&data); err != nil { + var claims *auth.JWTClaims + var err error + + if err = c.ShouldBind(&data); err != nil { + logrus.Errorf("Did not bind correctly: %v", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - claims, err := h.parseJWT(data.Token) + if util.GetConfig().AuthBackend == "proxy" { + // for proxy auth, we trust that the proxy has taken care of things + // for us and we are only testing that the middleware successfully + // pulled the necessary headers from the request. + claims, err = h.fakeClaimsForProxy(c) + } else { + claims, err = h.parseJWT(data.Token) + } if err != nil { + logrus.Errorf("Could not parse auth data: %v", err) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{ + sessionData := gin.H{ "ID": claims.OAuthID, "Name": claims.OAuthName, "Picture": claims.OAuthPicture, "Provider": claims.OAuthProvider, - }) + } + logrus.Debugf("Found session data: %v", sessionData) + c.JSON(http.StatusOK, sessionData) } func (h *Handler) oAuthPropertiesEquals(c *gin.Context, oauthID, oauthProvider string) bool { diff --git a/handlers/handlers.go b/handlers/handlers.go index b72bf5a..154155c 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -39,12 +39,16 @@ func New(store stores.Store) (*Handler, error) { if err := h.setHandlers(); err != nil { return nil, errors.Wrap(err, "could not set handlers") } - if !DoNotPrivateKeyChecking { - if err := util.CheckForPrivateKey(); err != nil { - return nil, errors.Wrap(err, "could not check for private key") + if util.GetConfig().AuthBackend == "oauth" { + if !DoNotPrivateKeyChecking { + if err := util.CheckForPrivateKey(); err != nil { + return nil, errors.Wrap(err, "could not check for private key") + } } + h.initOAuth() + } else if util.GetConfig().AuthBackend == "proxy" { + h.initProxyAuth() } - h.initOAuth() return h, nil } @@ -76,7 +80,15 @@ func (h *Handler) setHandlers() error { } h.engine.Use(ginrus.Ginrus(logrus.StandardLogger(), time.RFC3339, false)) protected := h.engine.Group("/api/v1/protected") - protected.Use(h.authMiddleware) + if util.GetConfig().AuthBackend == "oauth" { + logrus.Info("Using OAuth auth backend") + protected.Use(h.oAuthMiddleware) + } else if util.GetConfig().AuthBackend == "proxy" { + logrus.Info("Using proxy auth backend") + protected.Use(h.proxyAuthMiddleware) + } else { + logrus.Fatalf("Auth backend method '%s' is not recognized", util.GetConfig().AuthBackend) + } protected.POST("/create", h.handleCreate) protected.POST("/lookup", h.handleLookup) protected.GET("/recent", h.handleRecent) diff --git a/static/public/images/proxy_user.png b/static/public/images/proxy_user.png new file mode 100644 index 0000000000000000000000000000000000000000..01716ae6f5969854463468b6bcf7fe13c37d696e GIT binary patch literal 2859 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 e-E7*bW|!0E{!7w^K9{vMbmaNnO literal 0 HcmV?d00001 diff --git a/static/src/index.js b/static/src/index.js index fcf6f4a..80d4615 100644 --- a/static/src/index.js +++ b/static/src/index.js @@ -16,20 +16,20 @@ import Visitors from './Visitors/Visitors' import util from './util/util' export default class BaseComponent extends Component { state = { - oAuthPopupOpened: true, + authPopupOpened: true, userData: {}, authorized: false, activeItem: "", - info: null + info: {} } handleItemClick = (e, { name }) => this.setState({ activeItem: name }) onOAuthClose = () => { - this.setState({ oAuthPopupOpened: true }) + this.setState({ authPopupOpened: true }) } - componentWillMount() { + componentDidMount() { fetch('/api/v1/info') .then(d => d.json()) .then(info => this.setState({ info })) @@ -85,16 +85,34 @@ export default class BaseComponent extends Component { } } + onProxyAuthOpen = () => { + // the token contents don't matter for proxy auth, but + // checkAuth() needs it to be set to something + window.localStorage.setItem('token', {"lorem": "ipsum"}); + this.checkAuth(); + this.setState({ authPopupOpened: false }) + } + handleLogout = () => { window.localStorage.removeItem("token") this.setState({ authorized: false }) } render() { - const { oAuthPopupOpened, authorized, activeItem, userData, info } = this.state + const { authPopupOpened, authorized, activeItem, userData, info } = this.state if (!authorized) { + if (Array.isArray(info.providers) && info.providers.includes("proxy")) { + // window.localStorage.setItem('token', {"lorem": "ipsum"}); + // this.checkAuth(); + return ( + + Authentication +

If you are seeing this, you have not successfully authenticated to the proxy.

+
+ ) + } else if (Array.isArray(info.providers)) { return ( - + Authentication @@ -123,6 +141,7 @@ export default class BaseComponent extends Component { ) + } } return ( @@ -149,9 +168,11 @@ export default class BaseComponent extends Component { }}> About - - Logout - + + {userData.Name && {userData.Name}} + {Array.isArray(info.providers) && !info.providers.includes("proxy") && + Logout} + } /> diff --git a/util/config.go b/util/config.go index ca47681..f158524 100644 --- a/util/config.go +++ b/util/config.go @@ -14,18 +14,20 @@ import ( // Configuration are the available config values type Configuration struct { - ListenAddr string `yaml:"ListenAddr" env:"LISTEN_ADDR"` - BaseURL string `yaml:"BaseURL" env:"BASE_URL"` - DataDir string `yaml:"DataDir" env:"DATA_DIR"` - Backend string `yaml:"Backend" env:"BACKEND"` - RedisHost string `yaml:"RedisHost" env:"REDIS_HOST"` - RedisPassword string `yaml:"RedisPassword" env:"REDIS_PASSWORD"` - UseSSL bool `yaml:"EnableSSL" env:"USE_SSL"` - EnableDebugMode bool `yaml:"EnableDebugMode" env:"ENABLE_DEBUG_MODE"` - ShortedIDLength int `yaml:"ShortedIDLength" env:"SHORTED_ID_LENGTH"` - Google oAuthConf `yaml:"Google" env:"GOOGLE"` - GitHub oAuthConf `yaml:"GitHub" env:"GITHUB"` - Microsoft oAuthConf `yaml:"Microsoft" env:"MICROSOFT"` + ListenAddr string `yaml:"ListenAddr" env:"LISTEN_ADDR"` + BaseURL string `yaml:"BaseURL" env:"BASE_URL"` + DataDir string `yaml:"DataDir" env:"DATA_DIR"` + Backend string `yaml:"Backend" env:"BACKEND"` + RedisHost string `yaml:"RedisHost" env:"REDIS_HOST"` + RedisPassword string `yaml:"RedisPassword" env:"REDIS_PASSWORD"` + AuthBackend string `yaml:"AuthBackend" env:"AUTH_BACKEND"` + UseSSL bool `yaml:"EnableSSL" env:"USE_SSL"` + EnableDebugMode bool `yaml:"EnableDebugMode" env:"ENABLE_DEBUG_MODE"` + ShortedIDLength int `yaml:"ShortedIDLength" env:"SHORTED_ID_LENGTH"` + Google oAuthConf `yaml:"Google" env:"GOOGLE"` + GitHub oAuthConf `yaml:"GitHub" env:"GITHUB"` + Microsoft oAuthConf `yaml:"Microsoft" env:"MICROSOFT"` + Proxy proxyAuthConf `yaml:"Proxy" env:"PROXY"` } type oAuthConf struct { @@ -33,6 +35,12 @@ type oAuthConf struct { ClientSecret string `yaml:"ClientSecret" env:"CLIENT_SECRET"` } +type proxyAuthConf struct { + RequireUserHeader bool `yaml:"RequireUserHeader" env:"REQUIRE_USER_HEADER"` + UserHeader string `yaml:"UserHeader" env:"USER_HEADER"` + DisplayNameHeader string `yaml:"DisplayNameHeader" env:"DISPLAY_NAME_HEADER"` +} + // config contains the default values var config = Configuration{ ListenAddr: ":8080", @@ -42,6 +50,7 @@ var config = Configuration{ EnableDebugMode: false, UseSSL: false, ShortedIDLength: 4, + AuthBackend: "oauth", } // ReadInConfig loads the Configuration and other needed folders for further usage