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 0000000..01716ae Binary files /dev/null and b/static/public/images/proxy_user.png differ 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
If you are seeing this, you have not successfully authenticated to the proxy.