From c71e55ecc4c2381785b5f8ae10af74d8a537d6c3 Mon Sep 17 00:00:00 2001 From: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 7 Jul 2021 15:46:42 +0200 Subject: [PATCH] clean up some weirdness in the router (#80) --- internal/api/client/auth/authorize.go | 11 ++- internal/cliactions/server/server.go | 3 +- internal/cliactions/testrig/testrig.go | 2 +- internal/gtsmodel/routersession.go | 26 ++++++ internal/router/attach.go | 41 +++++++++ internal/router/cors.go | 88 +++++++++++++++++++ internal/router/router.go | 117 +++++++++---------------- internal/router/session.go | 100 +++++++++++++++++++++ internal/router/template.go | 23 +++++ testrig/db.go | 1 + testrig/router.go | 9 +- 11 files changed, 336 insertions(+), 85 deletions(-) create mode 100644 internal/gtsmodel/routersession.go create mode 100644 internal/router/attach.go create mode 100644 internal/router/cors.go create mode 100644 internal/router/session.go create mode 100644 internal/router/template.go diff --git a/internal/api/client/auth/authorize.go b/internal/api/client/auth/authorize.go index f473579db..7661019db 100644 --- a/internal/api/client/auth/authorize.go +++ b/internal/api/client/auth/authorize.go @@ -38,6 +38,9 @@ import ( func (m *Module) AuthorizeGETHandler(c *gin.Context) { l := m.log.WithField("func", "AuthorizeGETHandler") s := sessions.Default(c) + s.Options(sessions.Options{ + MaxAge: 120, // give the user 2 minutes to sign in before expiring their session + }) // UserID will be set in the session by AuthorizePOSTHandler if the caller has already gone through the authentication flow // If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page. @@ -117,9 +120,6 @@ func (m *Module) AuthorizePOSTHandler(c *gin.Context) { l := m.log.WithField("func", "AuthorizePOSTHandler") s := sessions.Default(c) - // At this point we know the user has said 'yes' to allowing the application and oauth client - // work for them, so we can set the - // We need to retrieve the original form submitted to the authorizeGEThandler, and // recreate it on the request so that it can be used further by the oauth2 library. // So first fetch all the values from the session. @@ -153,8 +153,13 @@ func (m *Module) AuthorizePOSTHandler(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "session missing userid"}) return } + // we're done with the session so we can clear it now s.Clear() + if err := s.Save(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } // now set the values on the request values := url.Values{} diff --git a/internal/cliactions/server/server.go b/internal/cliactions/server/server.go index 4864dacb4..dfe05f47a 100644 --- a/internal/cliactions/server/server.go +++ b/internal/cliactions/server/server.go @@ -67,6 +67,7 @@ var models []interface{} = []interface{}{ >smodel.Emoji{}, >smodel.Instance{}, >smodel.Notification{}, + >smodel.RouterSession{}, &oauth.Token{}, &oauth.Client{}, } @@ -94,7 +95,7 @@ var Start cliactions.GTSAction = func(ctx context.Context, c *config.Config, log federatingDB := federatingdb.New(dbService, c, log) - router, err := router.New(c, log) + router, err := router.New(c, dbService, log) if err != nil { return fmt.Errorf("error creating router: %s", err) } diff --git a/internal/cliactions/testrig/testrig.go b/internal/cliactions/testrig/testrig.go index a1d2d7af7..43d2db726 100644 --- a/internal/cliactions/testrig/testrig.go +++ b/internal/cliactions/testrig/testrig.go @@ -44,7 +44,7 @@ var Start cliactions.GTSAction = func(ctx context.Context, _ *config.Config, log c := testrig.NewTestConfig() dbService := testrig.NewTestDB() testrig.StandardDBSetup(dbService) - router := testrig.NewTestRouter() + router := testrig.NewTestRouter(dbService) storageBackend := testrig.NewTestStorage() testrig.StandardStorageSetup(storageBackend, "./testrig/media") diff --git a/internal/gtsmodel/routersession.go b/internal/gtsmodel/routersession.go new file mode 100644 index 000000000..c0f8e1f4d --- /dev/null +++ b/internal/gtsmodel/routersession.go @@ -0,0 +1,26 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package gtsmodel + +// RouterSession is used to store and retrieve settings for a router session. +type RouterSession struct { + ID string `pg:"type:CHAR(26),pk,notnull"` + Auth []byte `pg:",notnull"` + Crypt []byte `pg:",notnull"` +} diff --git a/internal/router/attach.go b/internal/router/attach.go new file mode 100644 index 000000000..2abfa0e0e --- /dev/null +++ b/internal/router/attach.go @@ -0,0 +1,41 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package router + +import "github.com/gin-gonic/gin" + +// AttachHandler attaches the given gin.HandlerFunc to the router with the specified method and path. +// If the path is set to ANY, then the handlerfunc will be used for ALL methods at its given path. +func (r *router) AttachHandler(method string, path string, handler gin.HandlerFunc) { + if method == "ANY" { + r.engine.Any(path, handler) + } else { + r.engine.Handle(method, path, handler) + } +} + +// AttachMiddleware attaches a gin middleware to the router that will be used globally +func (r *router) AttachMiddleware(middleware gin.HandlerFunc) { + r.engine.Use(middleware) +} + +// AttachNoRouteHandler attaches a gin.HandlerFunc to NoRoute to handle 404's +func (r *router) AttachNoRouteHandler(handler gin.HandlerFunc) { + r.engine.NoRoute(handler) +} diff --git a/internal/router/cors.go b/internal/router/cors.go new file mode 100644 index 000000000..9f8d379dd --- /dev/null +++ b/internal/router/cors.go @@ -0,0 +1,88 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package router + +import ( + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/config" +) + +var corsConfig = cors.Config{ + // TODO: make this customizable so instance admins can specify an origin for CORS requests + AllowAllOrigins: true, + + // adds the following: + // "chrome-extension://" + // "safari-extension://" + // "moz-extension://" + // "ms-browser-extension://" + AllowBrowserExtensions: true, + AllowMethods: []string{ + "POST", + "PUT", + "DELETE", + "GET", + "PATCH", + "OPTIONS", + }, + AllowHeaders: []string{ + // basic cors stuff + "Origin", + "Content-Length", + "Content-Type", + + // needed to pass oauth bearer tokens + "Authorization", + + // needed for websocket upgrade requests + "Upgrade", + "Sec-WebSocket-Extensions", + "Sec-WebSocket-Key", + "Sec-WebSocket-Protocol", + "Sec-WebSocket-Version", + "Connection", + }, + AllowWebSockets: true, + ExposeHeaders: []string{ + // needed for accessing next/prev links when making GET timeline requests + "Link", + + // needed so clients can handle rate limits + "X-RateLimit-Reset", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-Request-Id", + + // websocket stuff + "Connection", + "Sec-WebSocket-Accept", + "Upgrade", + }, + MaxAge: 2 * time.Minute, +} + +// useCors attaches the corsConfig above to the given gin engine +func useCors(cfg *config.Config, engine *gin.Engine) error { + c := cors.New(corsConfig) + engine.Use(c) + return nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 1b8d899fa..a77b7071e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -20,22 +20,24 @@ package router import ( "context" - "crypto/rand" "fmt" "net/http" - "os" - "path/filepath" "time" - "github.com/gin-contrib/cors" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/memstore" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" "golang.org/x/crypto/acme/autocert" ) +var ( + readTimeout = 60 * time.Second + writeTimeout = 30 * time.Second + idleTimeout = 30 * time.Second + readHeaderTimeout = 30 * time.Second +) + // Router provides the REST interface for gotosocial, using gin. type Router interface { // Attach a gin handler to the router with the given method and path @@ -96,31 +98,17 @@ func (r *router) Stop(ctx context.Context) error { return r.srv.Shutdown(ctx) } -// AttachHandler attaches the given gin.HandlerFunc to the router with the specified method and path. -// If the path is set to ANY, then the handlerfunc will be used for ALL methods at its given path. -func (r *router) AttachHandler(method string, path string, handler gin.HandlerFunc) { - if method == "ANY" { - r.engine.Any(path, handler) - } else { - r.engine.Handle(method, path, handler) - } -} - -// AttachMiddleware attaches a gin middleware to the router that will be used globally -func (r *router) AttachMiddleware(middleware gin.HandlerFunc) { - r.engine.Use(middleware) -} - -// AttachNoRouteHandler attaches a gin.HandlerFunc to NoRoute to handle 404's -func (r *router) AttachNoRouteHandler(handler gin.HandlerFunc) { - r.engine.NoRoute(handler) -} - // New returns a new Router with the specified configuration, using the given logrus logger. -func New(config *config.Config, logger *logrus.Logger) (Router, error) { - lvl, err := logrus.ParseLevel(config.LogLevel) +// +// The given DB is only used in the New function for parsing config values, and is not otherwise +// pinned to the router. +func New(cfg *config.Config, db db.DB, logger *logrus.Logger) (Router, error) { + + // gin has different log modes; for convenience, we match the gin log mode to + // whatever log mode has been set for logrus + lvl, err := logrus.ParseLevel(cfg.LogLevel) if err != nil { - return nil, fmt.Errorf("couldn't parse log level %s to set router level: %s", config.LogLevel, err) + return nil, fmt.Errorf("couldn't parse log level %s to set router level: %s", cfg.LogLevel, err) } switch lvl { case logrus.TraceLevel, logrus.DebugLevel: @@ -131,52 +119,43 @@ func New(config *config.Config, logger *logrus.Logger) (Router, error) { // create the actual engine here -- this is the core request routing handler for gts engine := gin.Default() - engine.Use(cors.New(cors.Config{ - AllowAllOrigins: true, - AllowBrowserExtensions: true, - AllowMethods: []string{"POST", "PUT", "DELETE", "GET", "PATCH", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization", "Upgrade", "Sec-WebSocket-Extensions", "Sec-WebSocket-Key", "Sec-WebSocket-Protocol", "Sec-WebSocket-Version", "Connection"}, - AllowWebSockets: true, - ExposeHeaders: []string{"Link", "X-RateLimit-Reset", "X-RateLimit-Limit", " X-RateLimit-Remaining", "X-Request-Id", "Connection", "Sec-WebSocket-Accept", "Upgrade"}, - MaxAge: 2 * time.Minute, - })) engine.MaxMultipartMemory = 8 << 20 // 8 MiB - // create a new session store middleware - store, err := sessionStore() - if err != nil { - return nil, fmt.Errorf("error creating session store: %s", err) + // enable cors on the engine + if err := useCors(cfg, engine); err != nil { + return nil, err } - engine.Use(sessions.Sessions("gotosocial-session", store)) - // load html templates for use by the router - cwd, err := os.Getwd() - if err != nil { - return nil, fmt.Errorf("error getting current working directory: %s", err) + // load templates onto the engine + if err := loadTemplates(cfg, engine); err != nil { + return nil, err } - tmPath := filepath.Join(cwd, fmt.Sprintf("%s*", config.TemplateConfig.BaseDir)) - logger.Debugf("loading templates from %s", tmPath) - engine.LoadHTMLGlob(tmPath) - // create the actual http server here + // enable session store middleware on the engine + if err := useSession(cfg, db, engine); err != nil { + return nil, err + } + + // create the http server here, passing the gin engine as handler s := &http.Server{ Handler: engine, - ReadTimeout: 60 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 30 * time.Second, - ReadHeaderTimeout: 30 * time.Second, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + ReadHeaderTimeout: readHeaderTimeout, } - var m *autocert.Manager // We need to spawn the underlying server slightly differently depending on whether lets encrypt is enabled or not. // In either case, the gin engine will still be used for routing requests. - if config.LetsEncryptConfig.Enabled { + + var m *autocert.Manager + if cfg.LetsEncryptConfig.Enabled { // le IS enabled, so roll up an autocert manager for handling letsencrypt requests m = &autocert.Manager{ Prompt: autocert.AcceptTOS, - HostPolicy: autocert.HostWhitelist(config.Host), - Cache: autocert.DirCache(config.LetsEncryptConfig.CertDir), - Email: config.LetsEncryptConfig.EmailAddress, + HostPolicy: autocert.HostWhitelist(cfg.Host), + Cache: autocert.DirCache(cfg.LetsEncryptConfig.CertDir), + Email: cfg.LetsEncryptConfig.EmailAddress, } // and create an HTTPS server s.Addr = ":https" @@ -190,27 +169,11 @@ func New(config *config.Config, logger *logrus.Logger) (Router, error) { logger: logger, engine: engine, srv: s, - config: config, + config: cfg, certManager: m, }, nil } -// sessionStore returns a new session store with a random auth and encryption key. -// This means that cookies using the store will be reset if gotosocial is restarted! -func sessionStore() (memstore.Store, error) { - auth := make([]byte, 32) - crypt := make([]byte, 32) - - if _, err := rand.Read(auth); err != nil { - return nil, err - } - if _, err := rand.Read(crypt); err != nil { - return nil, err - } - - return memstore.NewStore(auth, crypt), nil -} - func httpsRedirect(w http.ResponseWriter, req *http.Request) { target := "https://" + req.Host + req.URL.Path diff --git a/internal/router/session.go b/internal/router/session.go new file mode 100644 index 000000000..a1ac09d28 --- /dev/null +++ b/internal/router/session.go @@ -0,0 +1,100 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package router + +import ( + "crypto/rand" + "errors" + "fmt" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/memstore" + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +func useSession(cfg *config.Config, dbService db.DB, engine *gin.Engine) error { + // check if we have a saved router session already + routerSessions := []*gtsmodel.RouterSession{} + if err := dbService.GetAll(&routerSessions); err != nil { + if _, ok := err.(db.ErrNoEntries); !ok { + // proper error occurred + return err + } + } + + var rs *gtsmodel.RouterSession + if len(routerSessions) == 1 { + // we have a router session stored + rs = routerSessions[0] + } else if len(routerSessions) == 0 { + // we have no router sessions so we need to create a new one + var err error + rs, err = routerSession(dbService) + if err != nil { + return fmt.Errorf("error creating new router session: %s", err) + } + } else { + // we should only have one router session stored ever + return errors.New("we had more than one router session in the db") + } + + if rs == nil { + return errors.New("error getting or creating router session: router session was nil") + } + + store := memstore.NewStore(rs.Auth, rs.Crypt) + sessionName := fmt.Sprintf("gotosocial-%s", cfg.Host) + engine.Use(sessions.Sessions(sessionName, store)) + return nil +} + +// routerSession generates a new router session with random auth and crypt bytes, +// puts it in the database for persistence, and returns it for use. +func routerSession(dbService db.DB) (*gtsmodel.RouterSession, error) { + auth := make([]byte, 32) + crypt := make([]byte, 32) + + if _, err := rand.Read(auth); err != nil { + return nil, err + } + if _, err := rand.Read(crypt); err != nil { + return nil, err + } + + rid, err := id.NewULID() + if err != nil { + return nil, err + } + + rs := >smodel.RouterSession{ + ID: rid, + Auth: auth, + Crypt: crypt, + } + + if err := dbService.Put(rs); err != nil { + return nil, err + } + + return rs, nil +} diff --git a/internal/router/template.go b/internal/router/template.go new file mode 100644 index 000000000..cd1eb11db --- /dev/null +++ b/internal/router/template.go @@ -0,0 +1,23 @@ +package router + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/config" +) + +// loadTemplates loads html templates for use by the given engine +func loadTemplates(cfg *config.Config, engine *gin.Engine) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("error getting current working directory: %s", err) + } + + tmPath := filepath.Join(cwd, fmt.Sprintf("%s*", cfg.TemplateConfig.BaseDir)) + + engine.LoadHTMLGlob(tmPath) + return nil +} diff --git a/testrig/db.go b/testrig/db.go index 5fa019adc..01cf93934 100644 --- a/testrig/db.go +++ b/testrig/db.go @@ -47,6 +47,7 @@ var testModels []interface{} = []interface{}{ >smodel.Emoji{}, >smodel.Instance{}, >smodel.Notification{}, + >smodel.RouterSession{}, &oauth.Token{}, &oauth.Client{}, } diff --git a/testrig/router.go b/testrig/router.go index 83ce7b602..5770191ea 100644 --- a/testrig/router.go +++ b/testrig/router.go @@ -18,11 +18,14 @@ package testrig -import "github.com/superseriousbusiness/gotosocial/internal/router" +import ( + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/router" +) // NewTestRouter returns a Router suitable for testing -func NewTestRouter() router.Router { - r, err := router.New(NewTestConfig(), NewTestLog()) +func NewTestRouter(db db.DB) router.Router { + r, err := router.New(NewTestConfig(), db, NewTestLog()) if err != nil { panic(err) }