From 495ab4c6957b6b978931ade419212756685d23f4 Mon Sep 17 00:00:00 2001 From: Aleksa Milosevic Date: Mon, 5 Jul 2021 00:19:21 +0200 Subject: [PATCH 01/13] Setup for general auth flow and google oauth --- go.mod | 3 +- go.sum | 6 +- pkg/config/config.go | 12 +++ pkg/server/controller.go | 195 +++++++++++++++++++++++++++++++++- webapp/templates/login.html | 119 +++++++++++++++++++++ webapp/templates/welcome.html | 79 ++++++++++++++ 6 files changed, 410 insertions(+), 4 deletions(-) create mode 100644 webapp/templates/login.html create mode 100644 webapp/templates/welcome.html diff --git a/go.mod b/go.mod index 324139db22..09ac464f90 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.14 require ( github.com/StackExchange/wmi v0.0.0-20210224194228-fe8f1750fd46 // indirect - github.com/avast/retry-go v3.0.0+incompatible github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 github.com/blang/semver v3.5.1+incompatible github.com/cheggaaa/pb/v3 v3.0.5 @@ -13,6 +12,7 @@ require ( github.com/creack/pty v1.1.11 // indirect github.com/davecgh/go-spew v1.1.1 github.com/dgraph-io/badger/v2 v2.2007.2 + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dgrijalva/lfu-go v0.0.0-20141010002404-f174e76c5138 github.com/fatih/color v1.10.0 github.com/felixge/fgprof v0.9.1 @@ -47,6 +47,7 @@ require ( github.com/tklauser/go-sysconf v0.3.6 // indirect github.com/twmb/murmur3 v1.1.5 github.com/wacul/ptr v1.0.0 // indirect + golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 golang.org/x/tools v0.1.0 diff --git a/go.sum b/go.sum index 1bdc757c17..54b8c8333c 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -31,8 +32,6 @@ github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5 github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= -github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= -github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= @@ -90,6 +89,7 @@ github.com/dgraph-io/badger/v2 v2.2007.2 h1:EjjK0KqwaFMlPin1ajhP943VPENHJdEz1KLI github.com/dgraph-io/badger/v2 v2.2007.2/go.mod h1:26P/7fbL4kUZVEVKLAKXkBXKOydDmM2p1e+NhhnBCAE= github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de h1:t0UHb5vdojIDUqktM6+xJAfScFBsVpXZmqC9dsgJmeA= github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= @@ -501,6 +501,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFMd9CgUAcGx7V+qEg/h5IBI= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -589,6 +590,7 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= diff --git a/pkg/config/config.go b/pkg/config/config.go index aa796f39c8..2a77fee04a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,6 +6,8 @@ import ( "github.com/pyroscope-io/pyroscope/pkg/util/bytesize" ) +const JWTSecret = "qC8=%q~'z'o'CBi" + type Config struct { Version bool @@ -80,6 +82,16 @@ type Server struct { CacheDictionarySize int `deprecated:"true"` CacheSegmentSize int `deprecated:"true"` CacheTreeSize int `deprecated:"true"` + + Enabled bool `def:"false" desc:"enables Google Oauth"` + GoogleClientID string `def:"" desc:"client ID generated for Google API"` + GoogleClientSecret string `def:"" desc:"client secret generated for Google API"` + GoogleRedirectURL string `def:"" desc:"url that google will redirect to after logging in. Has to be in form "` + GoogleScopes string `def:"https://www.googleapis.com/auth/userinfo.email" desc:"scopes for Google API"` + GoogleAuthURL string `def:"https://accounts.google.com/o/oauth2/auth" desc:"auth url for Google API (usually present in credentials.json file)"` + GoogleTokenURL string `def:"https://accounts.google.com/o/oauth2/token" desc:"token url for Google API (usually present in credentials.json file)"` + AllowedDomains string `def:"" desc:"allowed domains for Google API"` + AllowSignUp bool `def:"false" desc:"enables sign up for pyroscope using Google OAuth"` } type Convert struct { diff --git a/pkg/server/controller.go b/pkg/server/controller.go index 820c7bfef9..e88c3412b5 100644 --- a/pkg/server/controller.go +++ b/pkg/server/controller.go @@ -8,15 +8,19 @@ import ( golog "log" "net/http" "net/http/pprof" + "net/url" "os" + "strings" "sync" "sync/atomic" "text/template" "time" + "github.com/dgrijalva/jwt-go" "github.com/markbates/pkger" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/sirupsen/logrus" + "golang.org/x/oauth2" "github.com/pyroscope-io/pyroscope/pkg/build" "github.com/pyroscope-io/pyroscope/pkg/config" @@ -24,6 +28,8 @@ import ( "github.com/pyroscope-io/pyroscope/pkg/util/hyperloglog" ) +const cookieName = "pyroscopeJWT" + type Controller struct { drained uint32 @@ -71,7 +77,17 @@ func (ctrl *Controller) mux() http.Handler { {"/label-values", ctrl.labelValuesHandler}, } - addRoutes(mux, routes, ctrl.drainMiddleware) + addRoutes(mux, routes, ctrl.drainMiddleware, ctrl.authMiddleware) + + // auth routes: + authRoutes := []route{ + {"/google/login", ctrl.googleLoginHandler()}, + {"/google/callback", ctrl.googleCallbackHandler()}, + {"/login", ctrl.loginHandler()}, + {"/logout", ctrl.logoutHandler()}, + } + + addRoutes(mux, authRoutes) if !ctrl.config.DisablePprofEndpoint { addRoutes(mux, []route{ @@ -129,6 +145,47 @@ func (ctrl *Controller) drainMiddleware(next http.HandlerFunc) http.HandlerFunc } } +func (ctrl *Controller) authMiddleware(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jwtCookie, errCookie := r.Cookie(cookieName) + if errCookie != nil { + ctrl.httpServer.ErrorLog.Printf("There seems to be problem with jwt token cookie: %v", errCookie) + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return + } + + if jwtCookie == nil { + ctrl.httpServer.ErrorLog.Printf("Missing jwt cookie") + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return + } + + jwtToken := jwtCookie.Value + token, err := jwt.Parse(jwtToken, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + return []byte(config.JWTSecret), nil + }) + + if err != nil { + ctrl.httpServer.ErrorLog.Printf("Error parsing jwt token: %v", err) + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return + } + + if _, ok := token.Claims.(jwt.MapClaims); !ok || !token.Valid { + ctrl.httpServer.ErrorLog.Printf("Token not valid") + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return + } + + // TODO: Access logged in user information by replacing _ with claims above and uncommenting below + // value, ok := claims.(jwt.MapClaims)["key"] + next.ServeHTTP(w, r) + }) +} + func renderServerError(rw http.ResponseWriter, text string) { rw.WriteHeader(500) rw.Write([]byte(text)) @@ -145,6 +202,142 @@ type indexPage struct { BaseURL string } +func (ctrl *Controller) googleCallbackHandler() http.HandlerFunc { + type callbackResponse struct { + ID string + Email string + VerifiedEmail bool + Picture string + } + + return func(w http.ResponseWriter, r *http.Request) { + oauthConf := &oauth2.Config{ + ClientID: ctrl.config.GoogleClientID, + ClientSecret: ctrl.config.GoogleClientSecret, + RedirectURL: ctrl.config.GoogleRedirectURL, + Scopes: strings.Split(ctrl.config.GoogleScopes, " "), + Endpoint: oauth2.Endpoint{AuthURL: ctrl.config.GoogleAuthURL, TokenURL: ctrl.config.GoogleTokenURL}, + } + + code := r.FormValue("code") + if code == "" { + ctrl.httpServer.ErrorLog.Printf("Code not found") + w.Write([]byte("Code Not Found to provide AccessToken..\n")) + reason := r.FormValue("error_reason") + if reason == "user_denied" { + w.Write([]byte("User has denied Permission..")) + } + + return + } + + token, err := oauthConf.Exchange(oauth2.NoContext, code) + if err != nil { + ctrl.httpServer.ErrorLog.Printf("Exchanging auth code for token failed with %v ", err) + return + } + + resp, err := http.Get("https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + url.QueryEscape(token.AccessToken)) + if err != nil { + ctrl.httpServer.ErrorLog.Printf("Failed to get oauth user info: %v", err) + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return + } + defer resp.Body.Close() + + var userProfile callbackResponse + err = json.NewDecoder(resp.Body).Decode(&userProfile) + if err != nil { + ctrl.httpServer.ErrorLog.Printf("Decoding response body failed: %v", err) + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return + } + + jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + // TODO: Add if we want it to expire + // "exp": time.Now().Add(144 * time.Hour).Unix(), + // "iat": time.Now().Unix(), + "email": userProfile.Email, + }) + + tk, err := jwtToken.SignedString([]byte(config.JWTSecret)) + if err != nil { + ctrl.httpServer.ErrorLog.Printf("Signing jwt failed: %v", err) + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return + } + + // TODO: Should user be logged out once google token expires? + refreshCookie := &http.Cookie{ + Name: cookieName, + Path: "/", + Value: tk, + HttpOnly: true, + MaxAge: 0, + SameSite: http.SameSiteStrictMode, + } + + http.SetCookie(w, refreshCookie) + tmplt := template.New("welcome.html") + tmplt, _ = tmplt.ParseFiles("./webapp/templates/welcome.html") + params := map[string]string{"Email": userProfile.Email} + + tmplt.Execute(w, params) + return + } +} + +func (ctrl *Controller) googleLoginHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + oauthConf := &oauth2.Config{ + ClientID: ctrl.config.GoogleClientID, + ClientSecret: ctrl.config.GoogleClientSecret, + RedirectURL: ctrl.config.GoogleRedirectURL, + Scopes: strings.Split(ctrl.config.GoogleScopes, " "), + Endpoint: oauth2.Endpoint{AuthURL: ctrl.config.GoogleAuthURL, TokenURL: ctrl.config.GoogleTokenURL}, + } + + URL, err := url.Parse(oauthConf.Endpoint.AuthURL) + if err != nil { + ctrl.httpServer.ErrorLog.Printf("Parse: " + err.Error()) + } + + ctrl.httpServer.ErrorLog.Printf(URL.String()) + parameters := url.Values{} + parameters.Add("client_id", oauthConf.ClientID) + parameters.Add("scope", strings.Join(oauthConf.Scopes, " ")) + parameters.Add("redirect_uri", oauthConf.RedirectURL) + parameters.Add("response_type", "code") + URL.RawQuery = parameters.Encode() + url := URL.String() + ctrl.httpServer.ErrorLog.Printf(url) + http.Redirect(w, r, url, http.StatusTemporaryRedirect) + } +} + +func (ctrl *Controller) loginHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./webapp/templates/login.html") + } +} + +func (ctrl *Controller) logoutHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + refreshCookie := &http.Cookie{ + Name: cookieName, + Path: "/", + Value: "", + HttpOnly: true, + // MaxAge -1 request cookie be deleted immediately + MaxAge: -1, + SameSite: http.SameSiteStrictMode, + } + + http.SetCookie(w, refreshCookie) + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + } +} + func (ctrl *Controller) indexHandler() http.HandlerFunc { var dir http.FileSystem if build.UseEmbeddedAssets { diff --git a/webapp/templates/login.html b/webapp/templates/login.html new file mode 100644 index 0000000000..f0a94b495f --- /dev/null +++ b/webapp/templates/login.html @@ -0,0 +1,119 @@ + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + +
+
+ + + + \ No newline at end of file diff --git a/webapp/templates/welcome.html b/webapp/templates/welcome.html new file mode 100644 index 0000000000..bfd9720d26 --- /dev/null +++ b/webapp/templates/welcome.html @@ -0,0 +1,79 @@ + + + + + + + + + + + + +
+ +
+ + +

Logged in with {{.Email}}

+ + + + + +
+
+ + + + \ No newline at end of file From aaf118fef341c93669ae32bfdc696cc07eb31c30 Mon Sep 17 00:00:00 2001 From: Aleksa Milosevic Date: Tue, 6 Jul 2021 00:59:46 +0200 Subject: [PATCH 02/13] Added github and gitlab oauth flows. Added CSRF protection. Added redirect route --- pkg/config/config.go | 24 ++- pkg/server/controller.go | 311 +++++++++++++++++++++++++++------ webapp/templates/login.html | 9 +- webapp/templates/redirect.html | 20 +++ webapp/templates/welcome.html | 5 +- 5 files changed, 306 insertions(+), 63 deletions(-) create mode 100644 webapp/templates/redirect.html diff --git a/pkg/config/config.go b/pkg/config/config.go index 2a77fee04a..af903734e6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,8 +6,6 @@ import ( "github.com/pyroscope-io/pyroscope/pkg/util/bytesize" ) -const JWTSecret = "qC8=%q~'z'o'CBi" - type Config struct { Version bool @@ -83,7 +81,7 @@ type Server struct { CacheSegmentSize int `deprecated:"true"` CacheTreeSize int `deprecated:"true"` - Enabled bool `def:"false" desc:"enables Google Oauth"` + GoogleEnabled bool `def:"false" desc:"enables Google Oauth"` GoogleClientID string `def:"" desc:"client ID generated for Google API"` GoogleClientSecret string `def:"" desc:"client secret generated for Google API"` GoogleRedirectURL string `def:"" desc:"url that google will redirect to after logging in. Has to be in form "` @@ -92,6 +90,26 @@ type Server struct { GoogleTokenURL string `def:"https://accounts.google.com/o/oauth2/token" desc:"token url for Google API (usually present in credentials.json file)"` AllowedDomains string `def:"" desc:"allowed domains for Google API"` AllowSignUp bool `def:"false" desc:"enables sign up for pyroscope using Google OAuth"` + + GitlabEnabled bool `def:"false" desc:"enables Gitlab Oauth"` + GitlabApplicationID string `def:"" desc:"application ID generated for GitLab API"` + GitlabClientSecret string `def:"" desc:"client secret generated for GitLab API"` + GitlabRedirectURL string `def:"" desc:"url that gitlab will redirect to after logging in. Has to be in form "` + GitlabScopes string `def:"read_user" desc:"scopes for GitLab API"` + GitlabAuthURL string `def:"https://gitlab.com/oauth/authorize" desc:"auth url for GitLab API (usually https://gitlab.mycompany.com/oauth/authorize)"` + GitlabTokenURL string `def:"https://gitlab.com/oauth/token" desc:"token url for GitLab API (usually https://gitlab.mycompany.com/oauth/token)"` + GitlabAPIURL string `def:"https://gitlab.com/api/v4/user" desc:"URL to gitlab API, usually https://gitlab.com/api/v4/user for cloud and https://gitlab.mycompany.com/api/v4/user for on-premise solution"` + + GithubEnabled bool `def:"false" desc:"enables Github Oauth"` + GithubClientID string `def:"" desc:"client ID generated for Github API"` + GithubClientSecret string `def:"" desc:"client secret generated for Github API"` + GithubRedirectURL string `def:"" desc:"url that Github will redirect to after logging in. Has to be in form "` + GithubScopes string `def:"read:user user:email" desc:"scopes for Github API"` + GithubAuthURL string `def:"https://github.com/login/oauth/authorize" desc:"auth url for Github API"` + GithubTokenURL string `def:"https://github.com/login/oauth/access_token" desc:"token url for Github API"` + + JWTSecret string `def:"qC8=%q~'z'o'CBi" desc:"secret used to secure JWT"` + LoginMaximumLifetimeDays int `def:"0" desc:"amount of days after which user will be logged out"` } type Convert struct { diff --git a/pkg/server/controller.go b/pkg/server/controller.go index e88c3412b5..5550faa04f 100644 --- a/pkg/server/controller.go +++ b/pkg/server/controller.go @@ -2,6 +2,8 @@ package server import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io/ioutil" @@ -28,7 +30,10 @@ import ( "github.com/pyroscope-io/pyroscope/pkg/util/hyperloglog" ) -const cookieName = "pyroscopeJWT" +const ( + jwtCookieName = "pyroscopeJWT" + stateCookieName = "pyroscopeState" +) type Controller struct { drained uint32 @@ -68,6 +73,9 @@ func (ctrl *Controller) mux() http.Handler { {"/build", ctrl.buildHandler}, }) + // auth routes + addRoutes(mux, ctrl.getAuthRoutes()) + // drainable routes: routes := []route{ {"/", ctrl.indexHandler()}, @@ -79,16 +87,6 @@ func (ctrl *Controller) mux() http.Handler { addRoutes(mux, routes, ctrl.drainMiddleware, ctrl.authMiddleware) - // auth routes: - authRoutes := []route{ - {"/google/login", ctrl.googleLoginHandler()}, - {"/google/callback", ctrl.googleCallbackHandler()}, - {"/login", ctrl.loginHandler()}, - {"/logout", ctrl.logoutHandler()}, - } - - addRoutes(mux, authRoutes) - if !ctrl.config.DisablePprofEndpoint { addRoutes(mux, []route{ {"/debug/pprof/", pprof.Index}, @@ -101,6 +99,70 @@ func (ctrl *Controller) mux() http.Handler { return mux } +func getNewRedirectURL(url string) string { + splitRedirect := strings.Split(url, "/") + splitRedirect[len(splitRedirect)-1] = "redirect" + return strings.Join(splitRedirect, "/") +} + +func (ctrl *Controller) getAuthRoutes() []route { + authRoutes := []route{ + {"/login", ctrl.loginHandler()}, + {"/logout", ctrl.logoutHandler()}, + } + + if ctrl.config.GoogleEnabled { + googleOauthConfig := &oauth2.Config{ + ClientID: ctrl.config.GoogleClientID, + ClientSecret: ctrl.config.GoogleClientSecret, + RedirectURL: ctrl.config.GoogleRedirectURL, + Scopes: strings.Split(ctrl.config.GoogleScopes, " "), + Endpoint: oauth2.Endpoint{AuthURL: ctrl.config.GoogleAuthURL, TokenURL: ctrl.config.GoogleTokenURL}, + } + + authRoutes = append(authRoutes, []route{ + {"/google/login", ctrl.oauthLoginHandler(googleOauthConfig)}, + {"/google/callback", ctrl.callbackHandler(getNewRedirectURL(ctrl.config.GoogleRedirectURL))}, + {"/google/redirect", ctrl.callbacRedirectkHandler( + "https://www.googleapis.com/oauth2/v2/userinfo", googleOauthConfig, ctrl.decodeGoogleCallbackResponse)}, + }...) + } + + if ctrl.config.GithubEnabled { + gitHubOauthConfig := &oauth2.Config{ + ClientID: ctrl.config.GithubClientID, + ClientSecret: ctrl.config.GithubClientSecret, + RedirectURL: ctrl.config.GithubRedirectURL, + Scopes: strings.Split(ctrl.config.GithubScopes, " "), + Endpoint: oauth2.Endpoint{AuthURL: ctrl.config.GithubAuthURL, TokenURL: ctrl.config.GithubTokenURL}, + } + + authRoutes = append(authRoutes, []route{ + {"/github/login", ctrl.oauthLoginHandler(gitHubOauthConfig)}, + {"/github/callback", ctrl.callbackHandler(getNewRedirectURL(ctrl.config.GithubRedirectURL))}, + {"/github/redirect", ctrl.callbacRedirectkHandler("https://api.github.com/user", gitHubOauthConfig, ctrl.decodeGithubCallbackResponse)}, + }...) + } + + if ctrl.config.GitlabEnabled { + gitLabOauthConfig := &oauth2.Config{ + ClientID: ctrl.config.GitlabApplicationID, + ClientSecret: ctrl.config.GitlabClientSecret, + RedirectURL: ctrl.config.GitlabRedirectURL, + Scopes: strings.Split(ctrl.config.GitlabScopes, " "), + Endpoint: oauth2.Endpoint{AuthURL: ctrl.config.GitlabAuthURL, TokenURL: ctrl.config.GitlabTokenURL}, + } + + authRoutes = append(authRoutes, []route{ + {"/gitlab/login", ctrl.oauthLoginHandler(gitLabOauthConfig)}, + {"/gitlab/callback", ctrl.callbackHandler(getNewRedirectURL(ctrl.config.GitlabRedirectURL))}, + {"/gitlab/redirect", ctrl.callbacRedirectkHandler(ctrl.config.GitlabAPIURL, gitLabOauthConfig, ctrl.decodeGitLabCallbackResponse)}, + }...) + } + + return authRoutes +} + func (ctrl *Controller) Start() error { logger := logrus.New() w := logger.Writer() @@ -147,9 +209,9 @@ func (ctrl *Controller) drainMiddleware(next http.HandlerFunc) http.HandlerFunc func (ctrl *Controller) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - jwtCookie, errCookie := r.Cookie(cookieName) - if errCookie != nil { - ctrl.httpServer.ErrorLog.Printf("There seems to be problem with jwt token cookie: %v", errCookie) + jwtCookie, err := r.Cookie(jwtCookieName) + if err != nil { + ctrl.httpServer.ErrorLog.Printf("There seems to be problem with jwt token cookie: %v", err) http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) return } @@ -165,7 +227,7 @@ func (ctrl *Controller) authMiddleware(next http.HandlerFunc) http.HandlerFunc { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) } - return []byte(config.JWTSecret), nil + return []byte(ctrl.config.JWTSecret), nil }) if err != nil { @@ -174,14 +236,32 @@ func (ctrl *Controller) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return } - if _, ok := token.Claims.(jwt.MapClaims); !ok || !token.Valid { + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { ctrl.httpServer.ErrorLog.Printf("Token not valid") http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) return } - // TODO: Access logged in user information by replacing _ with claims above and uncommenting below - // value, ok := claims.(jwt.MapClaims)["key"] + if exp, ok := claims["exp"].(float64); ok && int64(exp) < time.Now().Unix() { + ctrl.httpServer.ErrorLog.Printf("Token no longer valid") + + refreshCookie := &http.Cookie{ + Name: jwtCookieName, + + Path: "/", + Value: "", + HttpOnly: true, + // MaxAge -1 request cookie be deleted immediately + MaxAge: -1, + SameSite: http.SameSiteStrictMode, + } + + http.SetCookie(w, refreshCookie) + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return + } + next.ServeHTTP(w, r) }) } @@ -202,7 +282,7 @@ type indexPage struct { BaseURL string } -func (ctrl *Controller) googleCallbackHandler() http.HandlerFunc { +func (ctrl *Controller) decodeGoogleCallbackResponse(resp *http.Response) (name string, err error) { type callbackResponse struct { ID string Email string @@ -210,13 +290,74 @@ func (ctrl *Controller) googleCallbackHandler() http.HandlerFunc { Picture string } + var userProfile callbackResponse + err = json.NewDecoder(resp.Body).Decode(&userProfile) + if err != nil { + return + } + + name = userProfile.Email + return +} + +func (ctrl *Controller) decodeGithubCallbackResponse(resp *http.Response) (name string, err error) { + type callbackResponse struct { + ID int64 + Email string + Login string + AvatarURL string + } + + var userProfile callbackResponse + err = json.NewDecoder(resp.Body).Decode(&userProfile) + if err != nil { + return + } + + name = userProfile.Login + return +} + +func (ctrl *Controller) decodeGitLabCallbackResponse(resp *http.Response) (name string, err error) { + type callbackResponse struct { + ID int64 + Email string + Username string + AvatarURL string + } + + var userProfile callbackResponse + err = json.NewDecoder(resp.Body).Decode(&userProfile) + if err != nil { + return + } + + name = userProfile.Username + return +} + +func (ctrl *Controller) callbacRedirectkHandler(getAccountInfoURL string, oauthConf *oauth2.Config, decodeResponse func(*http.Response) (string, error)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - oauthConf := &oauth2.Config{ - ClientID: ctrl.config.GoogleClientID, - ClientSecret: ctrl.config.GoogleClientSecret, - RedirectURL: ctrl.config.GoogleRedirectURL, - Scopes: strings.Split(ctrl.config.GoogleScopes, " "), - Endpoint: oauth2.Endpoint{AuthURL: ctrl.config.GoogleAuthURL, TokenURL: ctrl.config.GoogleTokenURL}, + cookie, err := r.Cookie(stateCookieName) + if err != nil { + ctrl.httpServer.ErrorLog.Printf("There seems to be problem with state cookie: %v", err) + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return + } + + if cookie == nil { + ctrl.httpServer.ErrorLog.Printf("Missing state cookie") + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return + } + + cookieState := cookie.Value + + state := r.FormValue("state") + if state != cookieState { + ctrl.httpServer.ErrorLog.Printf("invalid oauth state, expected %v got %v", cookieState, state) + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return } code := r.FormValue("code") @@ -237,7 +378,8 @@ func (ctrl *Controller) googleCallbackHandler() http.HandlerFunc { return } - resp, err := http.Get("https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + url.QueryEscape(token.AccessToken)) + client := oauthConf.Client(oauth2.NoContext, token) + resp, err := client.Get(getAccountInfoURL) if err != nil { ctrl.httpServer.ErrorLog.Printf("Failed to get oauth user info: %v", err) http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) @@ -245,31 +387,46 @@ func (ctrl *Controller) googleCallbackHandler() http.HandlerFunc { } defer resp.Body.Close() - var userProfile callbackResponse - err = json.NewDecoder(resp.Body).Decode(&userProfile) + name, err := decodeResponse(resp) if err != nil { ctrl.httpServer.ErrorLog.Printf("Decoding response body failed: %v", err) http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) return } - jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - // TODO: Add if we want it to expire - // "exp": time.Now().Add(144 * time.Hour).Unix(), - // "iat": time.Now().Unix(), - "email": userProfile.Email, - }) + claims := jwt.MapClaims{ + "iat": time.Now().Unix(), + "name": name, + } + + if ctrl.config.LoginMaximumLifetimeDays > 0 { + claims["exp"] = time.Now().Add(time.Hour * 24 * time.Duration(ctrl.config.LoginMaximumLifetimeDays)).Unix() + } - tk, err := jwtToken.SignedString([]byte(config.JWTSecret)) + jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + tk, err := jwtToken.SignedString([]byte(ctrl.config.JWTSecret)) if err != nil { ctrl.httpServer.ErrorLog.Printf("Signing jwt failed: %v", err) http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) return } - // TODO: Should user be logged out once google token expires? + // delete state cookie and add refresh cookie + stateCookie := &http.Cookie{ + Name: stateCookieName, + Path: "/", + Value: "", + HttpOnly: true, + // MaxAge -1 request cookie be deleted immediately + MaxAge: -1, + SameSite: http.SameSiteStrictMode, + } + + http.SetCookie(w, stateCookie) + refreshCookie := &http.Cookie{ - Name: cookieName, + Name: jwtCookieName, Path: "/", Value: tk, HttpOnly: true, @@ -280,51 +437,81 @@ func (ctrl *Controller) googleCallbackHandler() http.HandlerFunc { http.SetCookie(w, refreshCookie) tmplt := template.New("welcome.html") tmplt, _ = tmplt.ParseFiles("./webapp/templates/welcome.html") - params := map[string]string{"Email": userProfile.Email} + params := map[string]string{"Name": name} tmplt.Execute(w, params) return } } -func (ctrl *Controller) googleLoginHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - oauthConf := &oauth2.Config{ - ClientID: ctrl.config.GoogleClientID, - ClientSecret: ctrl.config.GoogleClientSecret, - RedirectURL: ctrl.config.GoogleRedirectURL, - Scopes: strings.Split(ctrl.config.GoogleScopes, " "), - Endpoint: oauth2.Endpoint{AuthURL: ctrl.config.GoogleAuthURL, TokenURL: ctrl.config.GoogleTokenURL}, - } +func generateStateToken(length int) (string, error) { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} +func (ctrl *Controller) oauthLoginHandler(oauthConf *oauth2.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { URL, err := url.Parse(oauthConf.Endpoint.AuthURL) if err != nil { - ctrl.httpServer.ErrorLog.Printf("Parse: " + err.Error()) + ctrl.httpServer.ErrorLog.Printf("Parse error: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return } - ctrl.httpServer.ErrorLog.Printf(URL.String()) parameters := url.Values{} parameters.Add("client_id", oauthConf.ClientID) parameters.Add("scope", strings.Join(oauthConf.Scopes, " ")) parameters.Add("redirect_uri", oauthConf.RedirectURL) parameters.Add("response_type", "code") + + // generate state token for CSRF protection + state, err := generateStateToken(16) + if err != nil { + ctrl.httpServer.ErrorLog.Printf("Generate token error: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + parameters.Add("state", state) URL.RawQuery = parameters.Encode() url := URL.String() - ctrl.httpServer.ErrorLog.Printf(url) + + stateCookie := &http.Cookie{ + Name: stateCookieName, + Path: "/", + Value: state, + HttpOnly: true, + MaxAge: 0, + SameSite: http.SameSiteStrictMode, + } + http.SetCookie(w, stateCookie) + http.Redirect(w, r, url, http.StatusTemporaryRedirect) } } func (ctrl *Controller) loginHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "./webapp/templates/login.html") + tmplt := template.New("login.html") + tmplt, _ = tmplt.ParseFiles("./webapp/templates/login.html") + params := map[string]bool{ + "GoogleEnabled": ctrl.config.GoogleEnabled, + "GithubEnabled": ctrl.config.GithubEnabled, + "GitlabEnabled": ctrl.config.GitlabEnabled, + } + + tmplt.Execute(w, params) } } func (ctrl *Controller) logoutHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { refreshCookie := &http.Cookie{ - Name: cookieName, + Name: jwtCookieName, + Path: "/", Value: "", HttpOnly: true, @@ -338,6 +525,26 @@ func (ctrl *Controller) logoutHandler() http.HandlerFunc { } } +// Instead of this handler that just redirects, Javascript code can be added to load the state and send it to backend +// this is done so that the state cookie would be send back from browser +func (ctrl *Controller) callbackHandler(callbackURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + parsedUrl, err := url.Parse(callbackURL) + if err != nil { + ctrl.httpServer.ErrorLog.Printf("Parse error: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + parsedUrl.RawQuery = r.URL.Query().Encode() + tmplt := template.New("redirect.html") + tmplt, _ = tmplt.ParseFiles("./webapp/templates/redirect.html") + params := map[string]string{"CallbackURL": parsedUrl.String()} + + tmplt.Execute(w, params) + } +} + func (ctrl *Controller) indexHandler() http.HandlerFunc { var dir http.FileSystem if build.UseEmbeddedAssets { diff --git a/webapp/templates/login.html b/webapp/templates/login.html index f0a94b495f..17f80d7f46 100644 --- a/webapp/templates/login.html +++ b/webapp/templates/login.html @@ -82,8 +82,7 @@ } - - + @@ -93,17 +92,17 @@
-