diff --git a/go.mod b/go.mod index 64c68ba..daa7849 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,10 @@ require ( github.com/go-fed/activity v1.0.0 github.com/go-pg/pg/extra/pgdebug v0.2.0 github.com/go-pg/pg/v10 v10.8.0 + github.com/go-session/session v3.1.2+incompatible // indirect 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.20210315164102-1f7842217e57 + github.com/gotosocial/oauth2/v4 v4.2.1-0.20210316171520-7b12112bbb88 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 b73d02c..f86cc74 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD87 github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/go-session/session v3.1.2+incompatible h1:yStchEObKg4nk2F7JGE7KoFIrA/1Y078peagMWcrncg= +github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0= github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg= github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -88,6 +90,8 @@ 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.20210315164102-1f7842217e57 h1:+zKsBEkg1cbz7zJDms1KMU9vJBeBAlElS1SbK/x0Rvc= github.com/gotosocial/oauth2/v4 v4.2.1-0.20210315164102-1f7842217e57/go.mod h1:zl5kwHf/atRUrY5yOyDnk49Us1Ygs0BzdW4jKAgoiP8= +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/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 8af9e75..9073618 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -19,16 +19,13 @@ package api import ( - "net/http" - "github.com/gin-gonic/gin" "github.com/gotosocial/gotosocial/internal/config" "github.com/sirupsen/logrus" ) type Server interface { - AttachHTTPHandler(method string, path string, handler http.HandlerFunc) - AttachGinHandler(method string, path string, handler gin.HandlerFunc) + AttachHandler(method string, path string, handler gin.HandlerFunc) // AttachMiddleware(handler gin.HandlerFunc) GetAPIGroup() *gin.RouterGroup Start() @@ -60,12 +57,12 @@ func (s *server) Stop() { // todo: shut down gracefully } -func (s *server) AttachHTTPHandler(method string, path string, handler http.HandlerFunc) { - s.engine.Handle(method, path, gin.WrapH(handler)) -} - -func (s *server) AttachGinHandler(method string, path string, handler gin.HandlerFunc) { - s.engine.Handle(method, path, handler) +func (s *server) AttachHandler(method string, path string, handler gin.HandlerFunc) { + if method == "ANY" { + s.engine.Any(path, handler) + } else { + s.engine.Handle(method, path, handler) + } } func New(config *config.Config, logger *logrus.Logger) Server { diff --git a/internal/oauth/html.go b/internal/oauth/html.go new file mode 100644 index 0000000..06089ae --- /dev/null +++ b/internal/oauth/html.go @@ -0,0 +1,68 @@ +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 050c23d..d877022 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -19,7 +19,14 @@ package oauth import ( + "bytes" + "net/http" + "net/url" + "time" + + "github.com/gin-gonic/gin" "github.com/go-pg/pg/v10" + "github.com/go-session/session" "github.com/gotosocial/gotosocial/internal/api" "github.com/gotosocial/gotosocial/internal/gtsmodel" "github.com/gotosocial/oauth2/v4" @@ -30,6 +37,8 @@ import ( "golang.org/x/crypto/bcrypt" ) +const methodAny = "ANY" + type API struct { manager *manage.Manager server *server.Server @@ -52,15 +61,24 @@ func New(ts oauth2.TokenStore, cs oauth2.ClientStore, conn *pg.DB, log *logrus.L log.Errorf("internal response error: %s", re.Error) }) - return &API{ + api := &API{ manager: manager, server: srv, conn: conn, log: log, } + + api.server.SetPasswordAuthorizationHandler(api.PasswordAuthorizationHandler) + api.server.SetUserAuthorizationHandler(api.UserAuthorizationHandler) + api.server.SetClientInfoHandler(server.ClientFormHandler) + return api } func (a *API) AddRoutes(s api.Server) error { + s.AttachHandler(methodAny, "/auth/sign_in", gin.WrapF(a.SignInHandler)) + s.AttachHandler(methodAny, "/oauth/token", gin.WrapF(a.TokenHandler)) + s.AttachHandler(methodAny, "/oauth/authorize", gin.WrapF(a.AuthorizeHandler)) + s.AttachHandler(methodAny, "/auth", gin.WrapF(a.AuthHandler)) return nil } @@ -68,7 +86,101 @@ func incorrectPassword() (string, error) { return "", errors.New("password/email combination was incorrect") } +/* + MAIN HANDLERS -- serve these through a server/router +*/ + +// SignInHandler should be served at https://example.org/auth/sign_in. +// The idea is to present a sign in page to the user, where they can enter their username and password. +// The handler will then redirect to the auth handler served at /auth +func (a *API) SignInHandler(w http.ResponseWriter, r *http.Request) { + store, err := session.Start(r.Context(), w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if r.Method == "POST" { + if r.Form == nil { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + store.Set("username", r.Form.Get("username")) + store.Save() + + w.Header().Set("Location", "/auth") + w.WriteHeader(http.StatusFound) + return + } + http.ServeContent(w, r, "sign_in.html", time.Unix(0, 0), bytes.NewReader([]byte(signInHTML))) +} + +// TokenHandler should be served 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(w http.ResponseWriter, r *http.Request) { + if err := a.server.HandleTokenRequest(w, r); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// AuthorizeHandler should be served 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) AuthorizeHandler(w http.ResponseWriter, r *http.Request) { + store, err := session.Start(nil, w, r) + if err != nil { + + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if _, ok := store.Get("username"); !ok { + w.Header().Set("Location", "/auth/sign_in") + w.WriteHeader(http.StatusFound) + return + } + + http.ServeContent(w, r, "authorize.html", time.Unix(0, 0), bytes.NewReader([]byte(authorizeHTML))) +} + +// AuthHandler should be served at https://example.org/auth +func (a *API) AuthHandler(w http.ResponseWriter, r *http.Request) { + store, err := session.Start(r.Context(), w, r) + if err != nil { + a.log.Errorf("error creating session in authhandler: %s", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var form url.Values + if v, ok := store.Get("ReturnUri"); ok { + form = v.(url.Values) + } + r.Form = form + + store.Delete("ReturnUri") + store.Save() + + if err := a.server.HandleAuthorizeRequest(w, r); err != nil { + a.log.Errorf("error in authhandler during handleauthorizerequest: %s", err) + http.Error(w, err.Error(), http.StatusBadRequest) + } +} + +/* + SUB-HANDLERS -- don't serve these directly +*/ + +// PasswordAuthorizationHandler takes a username (in this case, we use an email address) +// and a password. The goal is to authenticate the password against the one for that email +// address stored in the database. If OK, we return the userid (a uuid) for that user, +// so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db. func (a *API) PasswordAuthorizationHandler(email string, password string) (userid string, err error) { + a.log.Debugf("entering password authorization handler with email: %s and password: %s", email, password) + // 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 { @@ -93,3 +205,35 @@ func (a *API) PasswordAuthorizationHandler(email string, password string) (useri userid = gtsUser.ID return } + +// UserAuthorizationHandler gets the user's email address from the session key 'username' +// or redirects to the /auth/sign_in page, if this key is not present. +func (a *API) UserAuthorizationHandler(w http.ResponseWriter, r *http.Request) (string, error) { + + a.log.Errorf("entering userauthorizationhandler") + + sessionStore, err := session.Start(r.Context(), w, r) + if err != nil { + a.log.Errorf("error starting session: %s", err) + return "", err + } + + v, ok := sessionStore.Get("username") + if !ok { + if err := r.ParseForm(); err != nil { + a.log.Errorf("error parsing form: %s", err) + return "", err + } + + sessionStore.Set("ReturnUri", r.Form) + sessionStore.Save() + + w.Header().Set("Location", "/auth/sign_in") + w.WriteHeader(http.StatusFound) + return v.(string), nil + } + + sessionStore.Delete("username") + sessionStore.Save() + return v.(string), nil +} diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go new file mode 100644 index 0000000..4d942c5 --- /dev/null +++ b/internal/oauth/oauth_test.go @@ -0,0 +1,115 @@ +package oauth + +import ( + "context" + "testing" + "time" + + "github.com/go-pg/pg/v10" + "github.com/go-pg/pg/v10/orm" + "github.com/gotosocial/gotosocial/internal/api" + "github.com/gotosocial/gotosocial/internal/config" + "github.com/gotosocial/gotosocial/internal/gtsmodel" + "github.com/gotosocial/oauth2/v4" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" + "golang.org/x/crypto/bcrypt" +) + +type OauthTestSuite struct { + suite.Suite + tokenStore oauth2.TokenStore + clientStore oauth2.ClientStore + conn *pg.DB + testClientID string + testClientSecret string + testClientDomain string + testClientUserID string + testUser *gtsmodel.User + config *config.Config +} + +const () + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *OauthTestSuite) SetupSuite() { + suite.testClientID = "test-client-id" + suite.testClientSecret = "test-client-secret" + suite.testClientDomain = "https://example.org" + suite.testClientUserID = "test-client-user-id" + encryptedPassword, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.DefaultCost) + if err != nil { + logrus.Panicf("error encrypting user pass: %s", err) + } + suite.testUser = >smodel.User{ + EncryptedPassword: string(encryptedPassword), + Email: "user@example.org", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + AccountID: "whatever", + } +} + +// SetupTest creates a postgres connection and creates the oauth_clients table before each test +func (suite *OauthTestSuite) SetupTest() { + suite.conn = pg.Connect(&pg.Options{}) + if err := suite.conn.Ping(context.Background()); err != nil { + logrus.Panicf("db connection error: %s", err) + } + + models := []interface{}{ + &oauthClient{}, + &oauthToken{}, + >smodel.User{}, + } + + for _, m := range models { + if err := suite.conn.Model(m).CreateTable(&orm.CreateTableOptions{ + IfNotExists: true, + }); err != nil { + logrus.Panicf("db connection error: %s", err) + } + } + + suite.tokenStore = NewPGTokenStore(context.Background(), suite.conn, logrus.New()) + suite.clientStore = NewPGClientStore(suite.conn) + + if _, err := suite.conn.Model(suite.testUser).Insert(); err != nil { + logrus.Panicf("could not insert test user into db: %s", err) + } + +} + +// TearDownTest drops the oauth_clients table and closes the pg connection after each test +func (suite *OauthTestSuite) TearDownTest() { + models := []interface{}{ + &oauthClient{}, + &oauthToken{}, + >smodel.User{}, + } + for _, m := range models { + if err := suite.conn.Model(m).DropTable(&orm.DropTableOptions{}); err != nil { + logrus.Panicf("drop table error: %s", err) + } + } + if err := suite.conn.Close(); err != nil { + logrus.Panicf("error closing db connection: %s", err) + } + suite.conn = nil +} + +func (suite *OauthTestSuite) TestAPIInitialize() { + log := logrus.New() + log.SetLevel(logrus.DebugLevel) + + r := api.New(suite.config, log) + api := New(suite.tokenStore, suite.clientStore, suite.conn, log) + api.AddRoutes(r) + go r.Start() + time.Sleep(30 * time.Second) + // http://localhost:8080/oauth/authorize?client_id=whatever +} + +func TestOauthTestSuite(t *testing.T) { + suite.Run(t, new(OauthTestSuite)) +}