Some more messing around with oauth2

This commit is contained in:
tsmethurst 2021-03-17 11:33:06 +01:00
parent a4b70269ba
commit eb2ff2ab23
6 changed files with 341 additions and 12 deletions

3
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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 {

68
internal/oauth/html.go Normal file
View file

@ -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>`
)

View file

@ -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 := &gtsmodel.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
}

View file

@ -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 = &gtsmodel.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{},
&gtsmodel.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{},
&gtsmodel.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))
}