diff --git a/.gitignore b/.gitignore index fe72fe018..6f9b97023 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ # exclude built documentation, since readthedocs will build it for us anyway /docs/_build + +# exclude coverage report +cp.out diff --git a/cmd/gotosocial/main.go b/cmd/gotosocial/main.go index 0b4110656..96798035e 100644 --- a/cmd/gotosocial/main.go +++ b/cmd/gotosocial/main.go @@ -95,6 +95,14 @@ func main() { Value: "postgres", EnvVars: []string{envNames.DbDatabase}, }, + + // TEMPLATE FLAGS + &cli.StringFlag{ + Name: flagNames.TemplateBaseDir, + Usage: "Basedir for html templating files for rendering pages and composing emails", + Value: "./web/template/", + EnvVars: []string{envNames.TemplateBaseDir}, + }, }, Commands: []*cli.Command{ { @@ -137,12 +145,12 @@ func main() { func runAction(c *cli.Context, a action.GTSAction) error { // create a new *config.Config based on the config path provided... - conf, err := config.New(c.String(config.GetFlagNames().ConfigPath)) + conf, err := config.FromFile(c.String(config.GetFlagNames().ConfigPath)) if err != nil { return fmt.Errorf("error creating config: %s", err) } // ... and the flags set on the *cli.Context by urfave - conf.ParseFlags(c) + conf.ParseCLIFlags(c) // create a logger with the log level, formatting, and output splitter already set log, err := log.New(conf.LogLevel) diff --git a/example/config.yaml b/example/config.yaml index e4d05c634..b65149d9a 100644 --- a/example/config.yaml +++ b/example/config.yaml @@ -60,3 +60,10 @@ db: # Examples: ["mydb","postgres","gotosocial"] # Default: "postgres" database: "postgres" + +# Config pertaining to templating of web pages/email notifications and the like +template: + # String. Directory from which gotosocial will attempt to load html templates (.tmpl files). + # Examples: ["/some/absolute/path/", "./relative/path/", "../../some/weird/path/"] + # Default: "./web/template/" + baseDir: "./web/template/" diff --git a/go.mod b/go.mod index 6f1ede9ed..473ff3ea8 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/go-session/session v3.1.2+incompatible github.com/golang/mock v1.4.4 // indirect github.com/google/uuid v1.2.0 // indirect - github.com/gotosocial/oauth2/v4 v4.2.1-0.20210316171520-7b12112bbb88 + github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318133800-45d321d259b3 github.com/onsi/ginkgo v1.15.0 // indirect github.com/onsi/gomega v1.10.5 // indirect github.com/sirupsen/logrus v1.8.0 diff --git a/go.sum b/go.sum index f482187af..0b12241f0 100644 --- a/go.sum +++ b/go.sum @@ -107,6 +107,12 @@ github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0U github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gotosocial/oauth2/v4 v4.2.1-0.20210316171520-7b12112bbb88 h1:YJ//HmHOYJ4srm/LA6VPNjNisneMbY6TTM1xttV/ZQU= github.com/gotosocial/oauth2/v4 v4.2.1-0.20210316171520-7b12112bbb88/go.mod h1:zl5kwHf/atRUrY5yOyDnk49Us1Ygs0BzdW4jKAgoiP8= +github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318132047-b7df44000ea6 h1:mWWMTK2Boy6FSCi45WB6GVCcXW3IoTVJKJiHmmdjywU= +github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318132047-b7df44000ea6/go.mod h1:zl5kwHf/atRUrY5yOyDnk49Us1Ygs0BzdW4jKAgoiP8= +github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318132554-68b81fe90e62 h1:duqoA9NSY+BFY2IVveXx5lSvIQliVvPsaNMdspkTJPc= +github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318132554-68b81fe90e62/go.mod h1:zl5kwHf/atRUrY5yOyDnk49Us1Ygs0BzdW4jKAgoiP8= +github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318133800-45d321d259b3 h1:CKRz5d7mRum+UMR88Ue33tCYcej14WjUsB59C02DDqY= +github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318133800-45d321d259b3/go.mod h1:zl5kwHf/atRUrY5yOyDnk49Us1Ygs0BzdW4jKAgoiP8= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= diff --git a/internal/api/server.go b/internal/api/server.go index 321c932cd..8e22742bb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -19,6 +19,10 @@ package api import ( + "fmt" + "os" + "path/filepath" + "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/memstore" "github.com/gin-gonic/gin" @@ -71,6 +75,10 @@ func New(config *config.Config, logger *logrus.Logger) Server { engine := gin.New() store := memstore.NewStore([]byte("authentication-key"), []byte("encryption-keyencryption-key----")) engine.Use(sessions.Sessions("gotosocial-session", store)) + cwd, _ := os.Getwd() + tmPath := filepath.Join(cwd, fmt.Sprintf("%s*", config.TemplateConfig.BaseDir)) + logger.Debugf("loading templates from %s", tmPath) + engine.LoadHTMLGlob(tmPath) return &server{ APIGroup: engine.Group("/api").Group("/v1"), logger: logger, diff --git a/internal/config/config.go b/internal/config/config.go index 5832ed53f..8e2656e3f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,25 +27,38 @@ import ( // Config pulls together all the configuration needed to run gotosocial type Config struct { - LogLevel string `yaml:"logLevel"` - ApplicationName string `yaml:"applicationName"` - DBConfig *DBConfig `yaml:"db"` + LogLevel string `yaml:"logLevel"` + ApplicationName string `yaml:"applicationName"` + DBConfig *DBConfig `yaml:"db"` + TemplateConfig *TemplateConfig `yaml:"template"` } -// New returns a new config, or an error if something goes amiss. -// The path parameter is optional, for loading a configuration json from the given path. -func New(path string) (*Config, error) { - config := &Config{ - DBConfig: &DBConfig{}, - } - if path != "" { - var err error - if config, err = loadFromFile(path); err != nil { - return nil, fmt.Errorf("error creating config: %s", err) - } +// FromFile returns a new config from a file, or an error if something goes amiss. +func FromFile(path string) (*Config, error) { + c, err := loadFromFile(path) + if err != nil { + return nil, fmt.Errorf("error creating config: %s", err) } + return c, nil +} - return config, nil +// Default returns a new config with default values. +// Not yet implemented. +func Default() *Config { + // TODO: find a way of doing this without code repetition, because having to + // repeat all values here and elsewhere is annoying and gonna be prone to mistakes. + return &Config{ + DBConfig: &DBConfig{}, + TemplateConfig: &TemplateConfig{}, + } +} + +// Empty just returns an empty config +func Empty() *Config { + return &Config{ + DBConfig: &DBConfig{}, + TemplateConfig: &TemplateConfig{}, + } } // loadFromFile takes a path to a yaml file and attempts to load a Config object from it @@ -63,8 +76,8 @@ func loadFromFile(path string) (*Config, error) { return config, nil } -// ParseFlags sets flags on the config using the provided Flags object -func (c *Config) ParseFlags(f KeyedFlags) { +// ParseCLIFlags sets flags on the config using the provided Flags object +func (c *Config) ParseCLIFlags(f KeyedFlags) { fn := GetFlagNames() // For all of these flags, we only want to set them on the config if: @@ -108,6 +121,11 @@ func (c *Config) ParseFlags(f KeyedFlags) { if c.DBConfig.Database == "" || f.IsSet(fn.DbDatabase) { c.DBConfig.Database = f.String(fn.DbDatabase) } + + // template flags + if c.TemplateConfig.BaseDir == "" || f.IsSet(fn.TemplateBaseDir) { + c.TemplateConfig.BaseDir = f.String(fn.TemplateBaseDir) + } } // KeyedFlags is a wrapper for any type that can store keyed flags and give them back. @@ -130,6 +148,7 @@ type Flags struct { DbUser string DbPassword string DbDatabase string + TemplateBaseDir string } // GetFlagNames returns a struct containing the names of the various flags used for @@ -145,6 +164,7 @@ func GetFlagNames() Flags { DbUser: "db-user", DbPassword: "db-password", DbDatabase: "db-database", + TemplateBaseDir: "template-basedir", } } @@ -161,5 +181,6 @@ func GetEnvNames() Flags { DbUser: "GTS_DB_USER", DbPassword: "GTS_DB_PASSWORD", DbDatabase: "GTS_DB_DATABASE", + TemplateBaseDir: "GTS_TEMPLATE_BASEDIR", } } diff --git a/internal/config/template.go b/internal/config/template.go new file mode 100644 index 000000000..eba86f8e6 --- /dev/null +++ b/internal/config/template.go @@ -0,0 +1,25 @@ +/* + 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 config + +// TemplateConfig pertains to templating of web pages/email notifications and the like +type TemplateConfig struct { + // Directory from which gotosocial will attempt to load html templates (.tmpl files). + BaseDir string `yaml:"baseDir"` +} diff --git a/internal/oauth/html.go b/internal/oauth/html.go index 06089aedd..a3ae4318a 100644 --- a/internal/oauth/html.go +++ b/internal/oauth/html.go @@ -2,67 +2,8 @@ package oauth const ( signInHTML = ` - - - - - Login - - - - - - -
-

Login

-
-
- - -
-
- - -
- -
-
- - -` +` authorizeHTML = ` - - - - - Auth - - - - - - -
-
-
-

Authorize

-

The client would like to perform actions on your behalf.

-

- -

-
-
-
- -` +` ) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 97913549a..c6d596761 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -27,6 +27,7 @@ import ( "github.com/go-pg/pg/v10" "github.com/gotosocial/gotosocial/internal/api" "github.com/gotosocial/gotosocial/internal/gtsmodel" + "github.com/gotosocial/gotosocial/pkg/mastotypes" "github.com/gotosocial/oauth2/v4" "github.com/gotosocial/oauth2/v4/errors" "github.com/gotosocial/oauth2/v4/manage" @@ -45,16 +46,12 @@ type API struct { } type login struct { - Email string `form:"username"` + Email string `form:"username"` Password string `form:"password"` } -type authorize struct { - ForceLogin string `form:"force_login,omitempty"` - ResponseType string `form:"response_type"` - ClientID string `form:"client_id"` - RedirectURI string `form:"redirect_uri"` - Scope string `form:"scope,omitempty"` +type code struct { + Code string `form:"code"` } func New(ts oauth2.TokenStore, cs oauth2.ClientStore, conn *pg.DB, log *logrus.Logger) *API { @@ -79,6 +76,9 @@ func New(ts oauth2.TokenStore, cs oauth2.ClientStore, conn *pg.DB, log *logrus.L oauth2.AuthorizationCode, oauth2.Refreshing, }, + AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{ + oauth2.CodeChallengePlain, + }, } srv := server.NewServer(sc, manager) @@ -106,9 +106,13 @@ func New(ts oauth2.TokenStore, cs oauth2.ClientStore, conn *pg.DB, log *logrus.L func (a *API) AddRoutes(s api.Server) error { s.AttachHandler(http.MethodGet, "/auth/sign_in", a.SignInGETHandler) s.AttachHandler(http.MethodPost, "/auth/sign_in", a.SignInPOSTHandler) + s.AttachHandler(http.MethodPost, "/oauth/token", a.TokenHandler) + s.AttachHandler(http.MethodGet, "/oauth/authorize", a.AuthorizeGETHandler) - s.AttachHandler(methodAny, "/auth", a.AuthHandler) + s.AttachHandler(http.MethodPost, "/oauth/authorize", a.AuthorizePOSTHandler) + + // s.AttachHandler(http.MethodGet, "/auth", a.AuthGETHandler) return nil } @@ -125,7 +129,7 @@ func incorrectPassword() (string, error) { // The form will then POST to the sign in page, which will be handled by SignInPOSTHandler func (a *API) SignInGETHandler(c *gin.Context) { a.log.WithField("func", "SignInGETHandler").Trace("serving sign in html") - c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(signInHTML)) + c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{}) } // SignInPOSTHandler should be served at https://example.org/auth/sign_in. @@ -135,15 +139,16 @@ func (a *API) SignInPOSTHandler(c *gin.Context) { l := a.log.WithField("func", "SignInPOSTHandler") s := sessions.Default(c) form := &login{} - if err := c.ShouldBind(form); err != nil || form.Email == "" || form.Password == "" { + if err := c.ShouldBind(form); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } l.Tracef("parsed form: %+v", form) - userid, err := a.ValidatePassword(form.Email, form.Password); + userid, err := a.ValidatePassword(form.Email, form.Password) if err != nil { c.String(http.StatusForbidden, err.Error()) + return } s.Set("username", userid) @@ -151,11 +156,12 @@ func (a *API) SignInPOSTHandler(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + l.Trace("redirecting to auth page") - c.Redirect(http.StatusFound, "/auth") + c.Redirect(http.StatusFound, "/oauth/authorize") } -// TokenHandler should be served at https://example.org/oauth/token +// TokenHandler should be served as a POST at https://example.org/oauth/token // The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs. // See https://docs.joinmastodon.org/methods/apps/oauth/#obtain-a-token func (a *API) TokenHandler(c *gin.Context) { @@ -166,50 +172,61 @@ func (a *API) TokenHandler(c *gin.Context) { } } -// AuthorizeHandler should be served as GET at https://example.org/oauth/authorize +// AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize // The idea here is to present an oauth authorize page to the user, with a button // that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user func (a *API) AuthorizeGETHandler(c *gin.Context) { - l := a.log.WithField("func", "AuthorizeHandler") + l := a.log.WithField("func", "AuthorizeGETHandler") s := sessions.Default(c) - form := &authorize{} - - if err := c.ShouldBind(form); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - l.Tracef("parsed form: %+v", form) - - if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing one of: response_type, client_id or redirect_uri"}) - return - } - - s.Set("force_login", form.ForceLogin) - s.Set("response_type", form.ResponseType) - s.Set("client_id", form.ClientID) - s.Set("redirect_uri", form.RedirectURI) - s.Set("scope", form.Scope) - if err := s.Save(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } v := s.Get("username") if username, ok := v.(string); !ok || username == "" { - l.Trace("username was empty, redirecting to sign in page") + l.Trace("username was empty, parsing form then redirecting to sign in page") + + form := &mastotypes.OAuthAuthorize{} + + if err := c.ShouldBind(form); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + l.Tracef("parsed form: %+v", form) + + if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing one of: response_type, client_id or redirect_uri"}) + return + } + + // save these values from the form so we can use them elsewhere in the session + s.Set("force_login", form.ForceLogin) + s.Set("response_type", form.ResponseType) + s.Set("client_id", form.ClientID) + s.Set("redirect_uri", form.RedirectURI) + s.Set("scope", form.Scope) + if err := s.Save(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Redirect(http.StatusFound, "/auth/sign_in") return } l.Trace("serving authorize html") - c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(authorizeHTML)) + c.HTML(http.StatusOK, "authorize.tmpl", gin.H{}) } -// AuthHandler should be served at https://example.org/auth -func (a *API) AuthHandler(c *gin.Context) { - l := a.log.WithField("func", "AuthHandler") +// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize +// The idea here is to present an oauth authorize page to the user, with a button +// that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user +func (a *API) AuthorizePOSTHandler(c *gin.Context) { + l := a.log.WithField("func", "AuthorizePOSTHandler") s := sessions.Default(c) + v := s.Get("username") + if username, ok := v.(string); !ok || username == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "you are not signed in"}) + } + values := url.Values{} if v, ok := s.Get("force_login").(string); !ok { @@ -277,7 +294,13 @@ func (a *API) AuthHandler(c *gin.Context) { // so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db. func (a *API) ValidatePassword(email string, password string) (userid string, err error) { l := a.log.WithField("func", "PasswordAuthorizationHandler") - l.Tracef("email %s password %s", email, password) + + // make sure an email/password was provided and bail if not + if email == "" || password == "" { + l.Debug("email or password was not provided") + return incorrectPassword() + } + // first we select the user from the database based on email address, bail if no user found for that email gtsUser := >smodel.User{} if err := a.conn.Model(gtsUser).Where("email = ?", email).Select(); err != nil { @@ -297,8 +320,7 @@ func (a *API) ValidatePassword(email string, password string) (userid string, er return incorrectPassword() } - // If we've made it this far the email/password is correct so we need the oauth client-id of the user - // This is, conveniently, the same as the user ID, so we can just return it. + // If we've made it this far the email/password is correct, so we can just return the id of the user. userid = gtsUser.ID l.Tracef("returning (%s, %s)", userid, err) return diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index 404ea5c20..901f99b64 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -40,17 +40,24 @@ func (suite *OauthTestSuite) SetupSuite() { suite.testUser = >smodel.User{ ID: userID, EncryptedPassword: string(encryptedPassword), - Email: "user@localhost", + Email: "user@example.org", CreatedAt: time.Now(), UpdatedAt: time.Now(), - AccountID: "some-account-id-it-doesn't-matter-really", + AccountID: "some-account-id-it-doesn't-matter-really-since-this-user-doesn't-actually-have-an-account!", } suite.testClient = &oauthClient{ ID: "a-known-client-id", Secret: "some-secret", - Domain: "http://localhost:8080", + Domain: "https://example.org", UserID: userID, } + + // because go tests are run within the test package directory, we need to fiddle with the templateconfig + // basedir in a way that we wouldn't normally have to do when running the binary, in order to make + // the templates actually load + c := config.Empty() + c.TemplateConfig.BaseDir = "../../web/template/" + suite.config = c } // SetupTest creates a postgres connection and creates the oauth_clients table before each test @@ -114,7 +121,7 @@ func (suite *OauthTestSuite) TestAPIInitialize() { api.AddRoutes(r) go r.Start() time.Sleep(30 * time.Second) - // http://localhost:8080/oauth/authorize?client_id=a-known-client-id&redirect_uri=''&response_type=code + // http://localhost:8080/oauth/authorize?client_id=a-known-client-id&response_type=code&redirect_uri=https://example.org } func TestOauthTestSuite(t *testing.T) { diff --git a/pkg/mastotypes/oauth.go b/pkg/mastotypes/oauth.go new file mode 100644 index 000000000..1b45b38e0 --- /dev/null +++ b/pkg/mastotypes/oauth.go @@ -0,0 +1,37 @@ +/* + 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 mastotypes + +// OAuthAuthorize represents a request sent to https://example.org/oauth/authorize +// See here: https://docs.joinmastodon.org/methods/apps/oauth/ +type OAuthAuthorize struct { + // Forces the user to re-login, which is necessary for authorizing with multiple accounts from the same instance. + ForceLogin string `form:"force_login,omitempty"` + // Should be set equal to `code`. + ResponseType string `form:"response_type"` + // Client ID, obtained during app registration. + ClientID string `form:"client_id"` + // Set a URI to redirect the user to. + // If this parameter is set to urn:ietf:wg:oauth:2.0:oob then the authorization code will be shown instead. + // Must match one of the redirect URIs declared during app registration. + RedirectURI string `form:"redirect_uri"` + // List of requested OAuth scopes, separated by spaces (or by pluses, if using query parameters). + // Must be a subset of scopes declared during app registration. If not provided, defaults to read. + Scope string `form:"scope,omitempty"` +} diff --git a/web/template/authorize.tmpl b/web/template/authorize.tmpl new file mode 100644 index 000000000..0043e21ba --- /dev/null +++ b/web/template/authorize.tmpl @@ -0,0 +1,33 @@ + + + + + Auth + + + + + + +
+
+
+

Authorize

+

The client would like to perform actions on your behalf.

+

+ +

+
+
+
+ + diff --git a/web/template/sign-in.tmpl b/web/template/sign-in.tmpl new file mode 100644 index 000000000..b7aa7c702 --- /dev/null +++ b/web/template/sign-in.tmpl @@ -0,0 +1,28 @@ + + + + + Login + + + + + + +
+

Login

+
+
+ + +
+
+ + +
+ +
+
+ + +