commit eb2ff2ab23a70298c65f19d52b76c794b2f4937c
parent a4b70269bac29047de0395a3b58790c7bb51405c
Author: tsmethurst <tobi.smethurst@klarrio.com>
Date: Wed, 17 Mar 2021 11:33:06 +0100
Some more messing around with oauth2
Diffstat:
6 files changed, 341 insertions(+), 12 deletions(-)
diff --git 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
@@ -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
@@ -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
@@ -0,0 +1,68 @@
+package oauth
+
+const (
+ signInHTML = `
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Login</title>
+ <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
+ <script src="//code.jquery.com/jquery-2.2.4.min.js"></script>
+ <script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js"></script>
+</head>
+
+<body>
+ <div class="container">
+ <h1>Login</h1>
+ <form action="/auth/sign_in" method="POST">
+ <div class="form-group">
+ <label for="email">Email</label>
+ <input type="text" class="form-control" name="username" required placeholder="Please enter your email address">
+ </div>
+ <div class="form-group">
+ <label for="password">Password</label>
+ <input type="password" class="form-control" name="password" placeholder="Please enter your password">
+ </div>
+ <button type="submit" class="btn btn-success">Login</button>
+ </form>
+ </div>
+</body>
+
+</html>`
+
+ authorizeHTML = `
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <title>Auth</title>
+ <link
+ rel="stylesheet"
+ href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css"
+ />
+ <script src="//code.jquery.com/jquery-2.2.4.min.js"></script>
+ <script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js"></script>
+ </head>
+
+ <body>
+ <div class="container">
+ <div class="jumbotron">
+ <form action="/oauth/authorize" method="POST">
+ <h1>Authorize</h1>
+ <p>The client would like to perform actions on your behalf.</p>
+ <p>
+ <button
+ type="submit"
+ class="btn btn-primary btn-lg"
+ style="width:200px;"
+ >
+ Allow
+ </button>
+ </p>
+ </form>
+ </div>
+ </div>
+ </body>
+</html>`
+)
diff --git 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
@@ -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))
+}