diff options
| author | 2021-04-01 20:46:45 +0200 | |
|---|---|---|
| committer | 2021-04-01 20:46:45 +0200 | |
| commit | 71a49e2b43218d34f97b2276c43bdeb2df4a53d2 (patch) | |
| tree | 201c370b16cc5446740660f81f342e8171e9903f /internal/apimodule | |
| parent | Oauth/token (#7) (diff) | |
| download | gotosocial-71a49e2b43218d34f97b2276c43bdeb2df4a53d2.tar.xz | |
Api/v1/accounts (#8)
* start work on accounts module
* plodding away on the accounts endpoint
* groundwork for other account routes
* add password validator
* validation utils
* require account approval flags
* comments
* comments
* go fmt
* comments
* add distributor stub
* rename api to federator
* tidy a bit
* validate new account requests
* rename r router
* comments
* add domain blocks
* add some more shortcuts
* add some more shortcuts
* check email + username availability
* email block checking for signups
* chunking away at it
* tick off a few more things
* some fiddling with tests
* add mock package
* relocate repo
* move mocks around
* set app id on new signups
* initialize oauth server properly
* rename oauth server
* proper mocking tests
* go fmt ./...
* add required fields
* change name of func
* move validation to account.go
* more tests!
* add some file utility tools
* add mediaconfig
* new shortcut
* add some more fields
* add followrequest model
* add notify
* update mastotypes
* mock out storage interface
* start building media interface
* start on update credentials
* mess about with media a bit more
* test image manipulation
* media more or less working
* account update nearly working
* rearranging my package ;) ;) ;)
* phew big stuff!!!!
* fix type checking
* *fiddles*
* Add CreateTables func
* account registration flow working
* tidy
* script to step through auth flow
* add a lil helper for generating user uris
* fiddling with federation a bit
* update progress
* Tidying and linting
Diffstat (limited to 'internal/apimodule')
21 files changed, 2525 insertions, 0 deletions
diff --git a/internal/apimodule/account/account.go b/internal/apimodule/account/account.go new file mode 100644 index 000000000..2d9ddbb72 --- /dev/null +++ b/internal/apimodule/account/account.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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( +	"fmt" +	"net/http" +	"strings" + +	"github.com/gin-gonic/gin" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/apimodule" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( +	idKey                 = "id" +	basePath              = "/api/v1/accounts" +	basePathWithID        = basePath + "/:" + idKey +	verifyPath            = basePath + "/verify_credentials" +	updateCredentialsPath = basePath + "/update_credentials" +) + +type accountModule struct { +	config       *config.Config +	db           db.DB +	oauthServer  oauth.Server +	mediaHandler media.MediaHandler +	log          *logrus.Logger +} + +// New returns a new account module +func New(config *config.Config, db db.DB, oauthServer oauth.Server, mediaHandler media.MediaHandler, log *logrus.Logger) apimodule.ClientAPIModule { +	return &accountModule{ +		config:       config, +		db:           db, +		oauthServer:  oauthServer, +		mediaHandler: mediaHandler, +		log:          log, +	} +} + +// Route attaches all routes from this module to the given router +func (m *accountModule) Route(r router.Router) error { +	r.AttachHandler(http.MethodPost, basePath, m.accountCreatePOSTHandler) +	r.AttachHandler(http.MethodGet, basePathWithID, m.muxHandler) +	return nil +} + +func (m *accountModule) CreateTables(db db.DB) error { +	models := []interface{}{ +		&model.User{}, +		&model.Account{}, +		&model.Follow{}, +		&model.FollowRequest{}, +		&model.Status{}, +		&model.Application{}, +		&model.EmailDomainBlock{}, +		&model.MediaAttachment{}, +	} + +	for _, m := range models { +		if err := db.CreateTable(m); err != nil { +			return fmt.Errorf("error creating table: %s", err) +		} +	} +	return nil +} + +func (m *accountModule) muxHandler(c *gin.Context) { +	ru := c.Request.RequestURI +	if strings.HasPrefix(ru, verifyPath) { +		m.accountVerifyGETHandler(c) +	} else if strings.HasPrefix(ru, updateCredentialsPath) { +		m.accountUpdateCredentialsPATCHHandler(c) +	} else { +		m.accountGETHandler(c) +	} +} diff --git a/internal/apimodule/account/accountcreate.go b/internal/apimodule/account/accountcreate.go new file mode 100644 index 000000000..58b98c0e4 --- /dev/null +++ b/internal/apimodule/account/accountcreate.go @@ -0,0 +1,155 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( +	"errors" +	"fmt" +	"net" +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/util" +	"github.com/superseriousbusiness/gotosocial/pkg/mastotypes" +	"github.com/superseriousbusiness/oauth2/v4" +) + +// accountCreatePOSTHandler handles create account requests, validates them, +// and puts them in the database if they're valid. +// It should be served as a POST at /api/v1/accounts +func (m *accountModule) accountCreatePOSTHandler(c *gin.Context) { +	l := m.log.WithField("func", "accountCreatePOSTHandler") +	authed, err := oauth.MustAuth(c, true, true, false, false) +	if err != nil { +		l.Debugf("couldn't auth: %s", err) +		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) +		return +	} + +	l.Trace("parsing request form") +	form := &mastotypes.AccountCreateRequest{} +	if err := c.ShouldBind(form); err != nil || form == nil { +		l.Debugf("could not parse form from request: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) +		return +	} + +	l.Tracef("validating form %+v", form) +	if err := validateCreateAccount(form, m.config.AccountsConfig, m.db); err != nil { +		l.Debugf("error validating form: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +		return +	} + +	clientIP := c.ClientIP() +	l.Tracef("attempting to parse client ip address %s", clientIP) +	signUpIP := net.ParseIP(clientIP) +	if signUpIP == nil { +		l.Debugf("error validating sign up ip address %s", clientIP) +		c.JSON(http.StatusBadRequest, gin.H{"error": "ip address could not be parsed from request"}) +		return +	} + +	ti, err := m.accountCreate(form, signUpIP, authed.Token, authed.Application) +	if err != nil { +		l.Errorf("internal server error while creating new account: %s", err) +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		return +	} + +	c.JSON(http.StatusOK, ti) +} + +// accountCreate does the dirty work of making an account and user in the database. +// It then returns a token to the caller, for use with the new account, as per the +// spec here: https://docs.joinmastodon.org/methods/accounts/ +func (m *accountModule) accountCreate(form *mastotypes.AccountCreateRequest, signUpIP net.IP, token oauth2.TokenInfo, app *model.Application) (*mastotypes.Token, error) { +	l := m.log.WithField("func", "accountCreate") + +	// don't store a reason if we don't require one +	reason := form.Reason +	if !m.config.AccountsConfig.ReasonRequired { +		reason = "" +	} + +	l.Trace("creating new username and account") +	user, err := m.db.NewSignup(form.Username, reason, m.config.AccountsConfig.RequireApproval, form.Email, form.Password, signUpIP, form.Locale, app.ID) +	if err != nil { +		return nil, fmt.Errorf("error creating new signup in the database: %s", err) +	} + +	l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, app.ID) +	accessToken, err := m.oauthServer.GenerateUserAccessToken(token, app.ClientSecret, user.ID) +	if err != nil { +		return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err) +	} + +	return &mastotypes.Token{ +		AccessToken: accessToken.GetAccess(), +		TokenType:   "Bearer", +		Scope:       accessToken.GetScope(), +		CreatedAt:   accessToken.GetAccessCreateAt().Unix(), +	}, nil +} + +// validateCreateAccount checks through all the necessary prerequisites for creating a new account, +// according to the provided account create request. If the account isn't eligible, an error will be returned. +func validateCreateAccount(form *mastotypes.AccountCreateRequest, c *config.AccountsConfig, database db.DB) error { +	if !c.OpenRegistration { +		return errors.New("registration is not open for this server") +	} + +	if err := util.ValidateUsername(form.Username); err != nil { +		return err +	} + +	if err := util.ValidateEmail(form.Email); err != nil { +		return err +	} + +	if err := util.ValidateNewPassword(form.Password); err != nil { +		return err +	} + +	if !form.Agreement { +		return errors.New("agreement to terms and conditions not given") +	} + +	if err := util.ValidateLanguage(form.Locale); err != nil { +		return err +	} + +	if err := util.ValidateSignUpReason(form.Reason, c.ReasonRequired); err != nil { +		return err +	} + +	if err := database.IsEmailAvailable(form.Email); err != nil { +		return err +	} + +	if err := database.IsUsernameAvailable(form.Username); err != nil { +		return err +	} + +	return nil +} diff --git a/internal/apimodule/account/accountcreate_test.go b/internal/apimodule/account/accountcreate_test.go new file mode 100644 index 000000000..d14ae3852 --- /dev/null +++ b/internal/apimodule/account/accountcreate_test.go @@ -0,0 +1,545 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( +	"bytes" +	"context" +	"encoding/json" +	"fmt" +	"io" +	"io/ioutil" +	"mime/multipart" +	"net/http" +	"net/http/httptest" +	"net/url" +	"os" +	"testing" +	"time" + +	"github.com/gin-gonic/gin" +	"github.com/google/uuid" +	"github.com/sirupsen/logrus" +	"github.com/stretchr/testify/assert" +	"github.com/stretchr/testify/mock" +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/gotosocial/pkg/mastotypes" +	"github.com/superseriousbusiness/oauth2/v4" +	"github.com/superseriousbusiness/oauth2/v4/models" +	oauthmodels "github.com/superseriousbusiness/oauth2/v4/models" +	"golang.org/x/crypto/bcrypt" +) + +type AccountCreateTestSuite struct { +	suite.Suite +	config               *config.Config +	log                  *logrus.Logger +	testAccountLocal     *model.Account +	testApplication      *model.Application +	testToken            oauth2.TokenInfo +	mockOauthServer      *oauth.MockServer +	mockStorage          *storage.MockStorage +	mediaHandler         media.MediaHandler +	db                   db.DB +	accountModule        *accountModule +	newUserFormHappyPath url.Values +} + +/* +	TEST INFRASTRUCTURE +*/ + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *AccountCreateTestSuite) SetupSuite() { +	// some of our subsequent entities need a log so create this here +	log := logrus.New() +	log.SetLevel(logrus.TraceLevel) +	suite.log = log + +	suite.testAccountLocal = &model.Account{ +		ID:       uuid.NewString(), +		Username: "test_user", +	} + +	// can use this test application throughout +	suite.testApplication = &model.Application{ +		ID:           "weeweeeeeeeeeeeeee", +		Name:         "a test application", +		Website:      "https://some-application-website.com", +		RedirectURI:  "http://localhost:8080", +		ClientID:     "a-known-client-id", +		ClientSecret: "some-secret", +		Scopes:       "read", +		VapidKey:     "aaaaaa-aaaaaaaa-aaaaaaaaaaa", +	} + +	// can use this test token throughout +	suite.testToken = &oauthmodels.Token{ +		ClientID:      "a-known-client-id", +		RedirectURI:   "http://localhost:8080", +		Scope:         "read", +		Code:          "123456789", +		CodeCreateAt:  time.Now(), +		CodeExpiresIn: time.Duration(10 * time.Minute), +	} + +	// Direct config to local postgres instance +	c := config.Empty() +	c.Protocol = "http" +	c.Host = "localhost" +	c.DBConfig = &config.DBConfig{ +		Type:            "postgres", +		Address:         "localhost", +		Port:            5432, +		User:            "postgres", +		Password:        "postgres", +		Database:        "postgres", +		ApplicationName: "gotosocial", +	} +	c.MediaConfig = &config.MediaConfig{ +		MaxImageSize: 2 << 20, +	} +	c.StorageConfig = &config.StorageConfig{ +		Backend:       "local", +		BasePath:      "/tmp", +		ServeProtocol: "http", +		ServeHost:     "localhost", +		ServeBasePath: "/fileserver/media", +	} +	suite.config = c + +	// use an actual database for this, because it's just easier than mocking one out +	database, err := db.New(context.Background(), c, log) +	if err != nil { +		suite.FailNow(err.Error()) +	} +	suite.db = database + +	// we need to mock the oauth server because account creation needs it to create a new token +	suite.mockOauthServer = &oauth.MockServer{} +	suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) { +		l := suite.log.WithField("func", "GenerateUserAccessToken") +		token := args.Get(0).(oauth2.TokenInfo) +		l.Infof("received token %+v", token) +		clientSecret := args.Get(1).(string) +		l.Infof("received clientSecret %+v", clientSecret) +		userID := args.Get(2).(string) +		l.Infof("received userID %+v", userID) +	}).Return(&models.Token{ +		Code: "we're authorized now!", +	}, nil) + +	suite.mockStorage = &storage.MockStorage{} +	// We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage +	suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil) + +	// set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar) +	suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log) + +	// and finally here's the thing we're actually testing! +	suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.log).(*accountModule) +} + +func (suite *AccountCreateTestSuite) TearDownSuite() { +	if err := suite.db.Stop(context.Background()); err != nil { +		logrus.Panicf("error closing db connection: %s", err) +	} +} + +// SetupTest creates a db connection and creates necessary tables before each test +func (suite *AccountCreateTestSuite) SetupTest() { +	// create all the tables we might need in thie suite +	models := []interface{}{ +		&model.User{}, +		&model.Account{}, +		&model.Follow{}, +		&model.FollowRequest{}, +		&model.Status{}, +		&model.Application{}, +		&model.EmailDomainBlock{}, +		&model.MediaAttachment{}, +	} +	for _, m := range models { +		if err := suite.db.CreateTable(m); err != nil { +			logrus.Panicf("db connection error: %s", err) +		} +	} + +	// form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test +	suite.newUserFormHappyPath = url.Values{ +		"reason":    []string{"a very good reason that's at least 40 characters i swear"}, +		"username":  []string{"test_user"}, +		"email":     []string{"user@example.org"}, +		"password":  []string{"very-strong-password"}, +		"agreement": []string{"true"}, +		"locale":    []string{"en"}, +	} + +	// same with accounts config +	suite.config.AccountsConfig = &config.AccountsConfig{ +		OpenRegistration: true, +		RequireApproval:  true, +		ReasonRequired:   true, +	} +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *AccountCreateTestSuite) TearDownTest() { + +	// remove all the tables we might have used so it's clear for the next test +	models := []interface{}{ +		&model.User{}, +		&model.Account{}, +		&model.Follow{}, +		&model.FollowRequest{}, +		&model.Status{}, +		&model.Application{}, +		&model.EmailDomainBlock{}, +		&model.MediaAttachment{}, +	} +	for _, m := range models { +		if err := suite.db.DropTable(m); err != nil { +			logrus.Panicf("error dropping table: %s", err) +		} +	} +} + +/* +	ACTUAL TESTS +*/ + +/* +	TESTING: AccountCreatePOSTHandler +*/ + +// TestAccountCreatePOSTHandlerSuccessful checks the happy path for an account creation request: all the fields provided are valid, +// and at the end of it a new user and account should be added into the database. +// +// This is the handler served at /api/v1/accounts as POST +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerSuccessful() { + +	// setup +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting +	ctx.Request.Form = suite.newUserFormHappyPath +	suite.accountModule.accountCreatePOSTHandler(ctx) + +	// check response + +	// 1. we should have OK from our call to the function +	suite.EqualValues(http.StatusOK, recorder.Code) + +	// 2. we should have a token in the result body +	result := recorder.Result() +	defer result.Body.Close() +	b, err := ioutil.ReadAll(result.Body) +	assert.NoError(suite.T(), err) +	t := &mastotypes.Token{} +	err = json.Unmarshal(b, t) +	assert.NoError(suite.T(), err) +	assert.Equal(suite.T(), "we're authorized now!", t.AccessToken) + +	// check new account + +	// 1. we should be able to get the new account from the db +	acct := &model.Account{} +	err = suite.db.GetWhere("username", "test_user", acct) +	assert.NoError(suite.T(), err) +	assert.NotNil(suite.T(), acct) +	// 2. reason should be set +	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("reason"), acct.Reason) +	// 3. display name should be equal to username by default +	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("username"), acct.DisplayName) +	// 4. domain should be nil because this is a local account +	assert.Nil(suite.T(), nil, acct.Domain) +	// 5. id should be set and parseable as a uuid +	assert.NotNil(suite.T(), acct.ID) +	_, err = uuid.Parse(acct.ID) +	assert.Nil(suite.T(), err) +	// 6. private and public key should be set +	assert.NotNil(suite.T(), acct.PrivateKey) +	assert.NotNil(suite.T(), acct.PublicKey) + +	// check new user + +	// 1. we should be able to get the new user from the db +	usr := &model.User{} +	err = suite.db.GetWhere("unconfirmed_email", suite.newUserFormHappyPath.Get("email"), usr) +	assert.Nil(suite.T(), err) +	assert.NotNil(suite.T(), usr) + +	// 2. user should have account id set to account we got above +	assert.Equal(suite.T(), acct.ID, usr.AccountID) + +	// 3. id should be set and parseable as a uuid +	assert.NotNil(suite.T(), usr.ID) +	_, err = uuid.Parse(usr.ID) +	assert.Nil(suite.T(), err) + +	// 4. locale should be equal to what we requested +	assert.Equal(suite.T(), suite.newUserFormHappyPath.Get("locale"), usr.Locale) + +	// 5. created by application id should be equal to the app id +	assert.Equal(suite.T(), suite.testApplication.ID, usr.CreatedByApplicationID) + +	// 6. password should be matcheable to what we set above +	err = bcrypt.CompareHashAndPassword([]byte(usr.EncryptedPassword), []byte(suite.newUserFormHappyPath.Get("password"))) +	assert.Nil(suite.T(), err) +} + +// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no authorization is provided: +// only registered applications can create accounts, and we don't provide one here. +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoAuth() { + +	// setup +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting +	ctx.Request.Form = suite.newUserFormHappyPath +	suite.accountModule.accountCreatePOSTHandler(ctx) + +	// check response + +	// 1. we should have forbidden from our call to the function because we didn't auth +	suite.EqualValues(http.StatusForbidden, recorder.Code) + +	// 2. we should have an error message in the result body +	result := recorder.Result() +	defer result.Body.Close() +	b, err := ioutil.ReadAll(result.Body) +	assert.NoError(suite.T(), err) +	assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) +} + +// TestAccountCreatePOSTHandlerNoAuth makes sure that the handler fails when no form is provided at all. +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerNoForm() { + +	// setup +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting +	suite.accountModule.accountCreatePOSTHandler(ctx) + +	// check response +	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +	// 2. we should have an error message in the result body +	result := recorder.Result() +	defer result.Body.Close() +	b, err := ioutil.ReadAll(result.Body) +	assert.NoError(suite.T(), err) +	assert.Equal(suite.T(), `{"error":"missing one or more required form values"}`, string(b)) +} + +// TestAccountCreatePOSTHandlerWeakPassword makes sure that the handler fails when a weak password is provided +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeakPassword() { + +	// setup +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting +	ctx.Request.Form = suite.newUserFormHappyPath +	// set a weak password +	ctx.Request.Form.Set("password", "weak") +	suite.accountModule.accountCreatePOSTHandler(ctx) + +	// check response +	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +	// 2. we should have an error message in the result body +	result := recorder.Result() +	defer result.Body.Close() +	b, err := ioutil.ReadAll(result.Body) +	assert.NoError(suite.T(), err) +	assert.Equal(suite.T(), `{"error":"insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b)) +} + +// TestAccountCreatePOSTHandlerWeirdLocale makes sure that the handler fails when a weird locale is provided +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerWeirdLocale() { + +	// setup +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting +	ctx.Request.Form = suite.newUserFormHappyPath +	// set an invalid locale +	ctx.Request.Form.Set("locale", "neverneverland") +	suite.accountModule.accountCreatePOSTHandler(ctx) + +	// check response +	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +	// 2. we should have an error message in the result body +	result := recorder.Result() +	defer result.Body.Close() +	b, err := ioutil.ReadAll(result.Body) +	assert.NoError(suite.T(), err) +	assert.Equal(suite.T(), `{"error":"language: tag is not well-formed"}`, string(b)) +} + +// TestAccountCreatePOSTHandlerRegistrationsClosed makes sure that the handler fails when registrations are closed +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerRegistrationsClosed() { + +	// setup +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting +	ctx.Request.Form = suite.newUserFormHappyPath + +	// close registrations +	suite.config.AccountsConfig.OpenRegistration = false +	suite.accountModule.accountCreatePOSTHandler(ctx) + +	// check response +	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +	// 2. we should have an error message in the result body +	result := recorder.Result() +	defer result.Body.Close() +	b, err := ioutil.ReadAll(result.Body) +	assert.NoError(suite.T(), err) +	assert.Equal(suite.T(), `{"error":"registration is not open for this server"}`, string(b)) +} + +// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when no reason is provided but one is required +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerReasonNotProvided() { + +	// setup +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting +	ctx.Request.Form = suite.newUserFormHappyPath + +	// remove reason +	ctx.Request.Form.Set("reason", "") + +	suite.accountModule.accountCreatePOSTHandler(ctx) + +	// check response +	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +	// 2. we should have an error message in the result body +	result := recorder.Result() +	defer result.Body.Close() +	b, err := ioutil.ReadAll(result.Body) +	assert.NoError(suite.T(), err) +	assert.Equal(suite.T(), `{"error":"no reason provided"}`, string(b)) +} + +// TestAccountCreatePOSTHandlerReasonNotProvided makes sure that the handler fails when a crappy reason is presented but a good one is required +func (suite *AccountCreateTestSuite) TestAccountCreatePOSTHandlerInsufficientReason() { + +	// setup +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplication) +	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +	ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", basePath), nil) // the endpoint we're hitting +	ctx.Request.Form = suite.newUserFormHappyPath + +	// remove reason +	ctx.Request.Form.Set("reason", "just cuz") + +	suite.accountModule.accountCreatePOSTHandler(ctx) + +	// check response +	suite.EqualValues(http.StatusBadRequest, recorder.Code) + +	// 2. we should have an error message in the result body +	result := recorder.Result() +	defer result.Body.Close() +	b, err := ioutil.ReadAll(result.Body) +	assert.NoError(suite.T(), err) +	assert.Equal(suite.T(), `{"error":"reason should be at least 40 chars but 'just cuz' was 8"}`, string(b)) +} + +/* +	TESTING: AccountUpdateCredentialsPATCHHandler +*/ + +func (suite *AccountCreateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { + +	// put test local account in db +	err := suite.db.Put(suite.testAccountLocal) +	assert.NoError(suite.T(), err) + +	// attach avatar to request +	aviFile, err := os.Open("../../media/test/test-jpeg.jpg") +	assert.NoError(suite.T(), err) +	body := &bytes.Buffer{} +	writer := multipart.NewWriter(body) + +	part, err := writer.CreateFormFile("avatar", "test-jpeg.jpg") +	assert.NoError(suite.T(), err) + +	_, err = io.Copy(part, aviFile) +	assert.NoError(suite.T(), err) + +	err = aviFile.Close() +	assert.NoError(suite.T(), err) + +	err = writer.Close() +	assert.NoError(suite.T(), err) + +	// setup +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal) +	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +	ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", updateCredentialsPath), body) // the endpoint we're hitting +	ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) +	suite.accountModule.accountUpdateCredentialsPATCHHandler(ctx) + +	// check response + +	// 1. we should have OK because our request was valid +	suite.EqualValues(http.StatusOK, recorder.Code) + +	// 2. we should have an error message in the result body +	result := recorder.Result() +	defer result.Body.Close() +	// TODO: implement proper checks here +	// +	// b, err := ioutil.ReadAll(result.Body) +	// assert.NoError(suite.T(), err) +	// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) +} + +func TestAccountCreateTestSuite(t *testing.T) { +	suite.Run(t, new(AccountCreateTestSuite)) +} diff --git a/internal/apimodule/account/accountget.go b/internal/apimodule/account/accountget.go new file mode 100644 index 000000000..5ee93386d --- /dev/null +++ b/internal/apimodule/account/accountget.go @@ -0,0 +1,57 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +) + +// accountGetHandler serves the account information held by the server in response to a GET +// request. It should be served as a GET at /api/v1/accounts/:id. +// +// See: https://docs.joinmastodon.org/methods/accounts/ +func (m *accountModule) accountGETHandler(c *gin.Context) { +	targetAcctID := c.Param(idKey) +	if targetAcctID == "" { +		c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) +		return +	} + +	targetAccount := &model.Account{} +	if err := m.db.GetByID(targetAcctID, targetAccount); err != nil { +		if _, ok := err.(db.ErrNoEntries); ok { +			c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"}) +			return +		} +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		return +	} + +	acctInfo, err := m.db.AccountToMastoPublic(targetAccount) +	if err != nil { +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		return +	} + +	c.JSON(http.StatusOK, acctInfo) +} diff --git a/internal/apimodule/account/accountupdate.go b/internal/apimodule/account/accountupdate.go new file mode 100644 index 000000000..6686d3a50 --- /dev/null +++ b/internal/apimodule/account/accountupdate.go @@ -0,0 +1,259 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( +	"bytes" +	"errors" +	"fmt" +	"io" +	"mime/multipart" +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/util" +	"github.com/superseriousbusiness/gotosocial/pkg/mastotypes" +) + +// accountUpdateCredentialsPATCHHandler allows a user to modify their account/profile settings. +// It should be served as a PATCH at /api/v1/accounts/update_credentials +// +// TODO: this can be optimized massively by building up a picture of what we want the new account +// details to be, and then inserting it all in the database at once. As it is, we do queries one-by-one +// which is not gonna make the database very happy when lots of requests are going through. +// This way it would also be safer because the update won't happen until *all* the fields are validated. +// Otherwise we risk doing a partial update and that's gonna cause probllleeemmmsss. +func (m *accountModule) accountUpdateCredentialsPATCHHandler(c *gin.Context) { +	l := m.log.WithField("func", "accountUpdateCredentialsPATCHHandler") +	authed, err := oauth.MustAuth(c, true, false, false, true) +	if err != nil { +		l.Debugf("couldn't auth: %s", err) +		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) +		return +	} +	l.Tracef("retrieved account %+v", authed.Account.ID) + +	l.Trace("parsing request form") +	form := &mastotypes.UpdateCredentialsRequest{} +	if err := c.ShouldBind(form); err != nil || form == nil { +		l.Debugf("could not parse form from request: %s", err) +		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +		return +	} + +	// if everything on the form is nil, then nothing has been set and we shouldn't continue +	if form.Discoverable == nil && form.Bot == nil && form.DisplayName == nil && form.Note == nil && form.Avatar == nil && form.Header == nil && form.Locked == nil && form.Source == nil && form.FieldsAttributes == nil { +		l.Debugf("could not parse form from request") +		c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"}) +		return +	} + +	if form.Discoverable != nil { +		if err := m.db.UpdateOneByID(authed.Account.ID, "discoverable", *form.Discoverable, &model.Account{}); err != nil { +			l.Debugf("error updating discoverable: %s", err) +			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +			return +		} +	} + +	if form.Bot != nil { +		if err := m.db.UpdateOneByID(authed.Account.ID, "bot", *form.Bot, &model.Account{}); err != nil { +			l.Debugf("error updating bot: %s", err) +			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +			return +		} +	} + +	if form.DisplayName != nil { +		if err := util.ValidateDisplayName(*form.DisplayName); err != nil { +			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +			return +		} +		if err := m.db.UpdateOneByID(authed.Account.ID, "display_name", *form.DisplayName, &model.Account{}); err != nil { +			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +			return +		} +	} + +	if form.Note != nil { +		if err := util.ValidateNote(*form.Note); err != nil { +			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +			return +		} +		if err := m.db.UpdateOneByID(authed.Account.ID, "note", *form.Note, &model.Account{}); err != nil { +			l.Debugf("error updating note: %s", err) +			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +			return +		} +	} + +	if form.Avatar != nil && form.Avatar.Size != 0 { +		avatarInfo, err := m.UpdateAccountAvatar(form.Avatar, authed.Account.ID) +		if err != nil { +			l.Debugf("could not update avatar for account %s: %s", authed.Account.ID, err) +			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +			return +		} +		l.Tracef("new avatar info for account %s is %+v", authed.Account.ID, avatarInfo) +	} + +	if form.Header != nil && form.Header.Size != 0 { +		headerInfo, err := m.UpdateAccountHeader(form.Header, authed.Account.ID) +		if err != nil { +			l.Debugf("could not update header for account %s: %s", authed.Account.ID, err) +			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +			return +		} +		l.Tracef("new header info for account %s is %+v", authed.Account.ID, headerInfo) +	} + +	if form.Locked != nil { +		if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &model.Account{}); err != nil { +			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +			return +		} +	} + +	if form.Source != nil { +		if form.Source.Language != nil { +			if err := util.ValidateLanguage(*form.Source.Language); err != nil { +				c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +				return +			} +			if err := m.db.UpdateOneByID(authed.Account.ID, "language", *form.Source.Language, &model.Account{}); err != nil { +				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +				return +			} +		} + +		if form.Source.Sensitive != nil { +			if err := m.db.UpdateOneByID(authed.Account.ID, "locked", *form.Locked, &model.Account{}); err != nil { +				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +				return +			} +		} + +		if form.Source.Privacy != nil { +			if err := util.ValidatePrivacy(*form.Source.Privacy); err != nil { +				c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +				return +			} +			if err := m.db.UpdateOneByID(authed.Account.ID, "privacy", *form.Source.Privacy, &model.Account{}); err != nil { +				c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +				return +			} +		} +	} + +	// if form.FieldsAttributes != nil { +	// 	// TODO: parse fields attributes nicely and update +	// } + +	// fetch the account with all updated values set +	updatedAccount := &model.Account{} +	if err := m.db.GetByID(authed.Account.ID, updatedAccount); err != nil { +		l.Debugf("could not fetch updated account %s: %s", authed.Account.ID, err) +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		return +	} + +	acctSensitive, err := m.db.AccountToMastoSensitive(updatedAccount) +	if err != nil { +		l.Tracef("could not convert account into mastosensitive account: %s", err) +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		return +	} + +	l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive) +	c.JSON(http.StatusOK, acctSensitive) +} + +/* +	HELPER FUNCTIONS +*/ + +// TODO: try to combine the below two functions because this is a lot of code repetition. + +// UpdateAccountAvatar does the dirty work of checking the avatar part of an account update form, +// parsing and checking the image, and doing the necessary updates in the database for this to become +// the account's new avatar image. +func (m *accountModule) UpdateAccountAvatar(avatar *multipart.FileHeader, accountID string) (*model.MediaAttachment, error) { +	var err error +	if int(avatar.Size) > m.config.MediaConfig.MaxImageSize { +		err = fmt.Errorf("avatar with size %d exceeded max image size of %d bytes", avatar.Size, m.config.MediaConfig.MaxImageSize) +		return nil, err +	} +	f, err := avatar.Open() +	if err != nil { +		return nil, fmt.Errorf("could not read provided avatar: %s", err) +	} + +	// extract the bytes +	buf := new(bytes.Buffer) +	size, err := io.Copy(buf, f) +	if err != nil { +		return nil, fmt.Errorf("could not read provided avatar: %s", err) +	} +	if size == 0 { +		return nil, errors.New("could not read provided avatar: size 0 bytes") +	} + +	// do the setting +	avatarInfo, err := m.mediaHandler.SetHeaderOrAvatarForAccountID(buf.Bytes(), accountID, "avatar") +	if err != nil { +		return nil, fmt.Errorf("error processing avatar: %s", err) +	} + +	return avatarInfo, f.Close() +} + +// UpdateAccountHeader does the dirty work of checking the header part of an account update form, +// parsing and checking the image, and doing the necessary updates in the database for this to become +// the account's new header image. +func (m *accountModule) UpdateAccountHeader(header *multipart.FileHeader, accountID string) (*model.MediaAttachment, error) { +	var err error +	if int(header.Size) > m.config.MediaConfig.MaxImageSize { +		err = fmt.Errorf("header with size %d exceeded max image size of %d bytes", header.Size, m.config.MediaConfig.MaxImageSize) +		return nil, err +	} +	f, err := header.Open() +	if err != nil { +		return nil, fmt.Errorf("could not read provided header: %s", err) +	} + +	// extract the bytes +	buf := new(bytes.Buffer) +	size, err := io.Copy(buf, f) +	if err != nil { +		return nil, fmt.Errorf("could not read provided header: %s", err) +	} +	if size == 0 { +		return nil, errors.New("could not read provided header: size 0 bytes") +	} + +	// do the setting +	headerInfo, err := m.mediaHandler.SetHeaderOrAvatarForAccountID(buf.Bytes(), accountID, "header") +	if err != nil { +		return nil, fmt.Errorf("error processing header: %s", err) +	} + +	return headerInfo, f.Close() +} diff --git a/internal/apimodule/account/accountupdate_test.go b/internal/apimodule/account/accountupdate_test.go new file mode 100644 index 000000000..651b4d29d --- /dev/null +++ b/internal/apimodule/account/accountupdate_test.go @@ -0,0 +1,298 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( +	"bytes" +	"context" +	"fmt" +	"io" +	"mime/multipart" +	"net/http" +	"net/http/httptest" +	"net/url" +	"os" +	"testing" +	"time" + +	"github.com/gin-gonic/gin" +	"github.com/google/uuid" +	"github.com/sirupsen/logrus" +	"github.com/stretchr/testify/assert" +	"github.com/stretchr/testify/mock" +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/internal/media" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/storage" +	"github.com/superseriousbusiness/oauth2/v4" +	"github.com/superseriousbusiness/oauth2/v4/models" +	oauthmodels "github.com/superseriousbusiness/oauth2/v4/models" +) + +type AccountUpdateTestSuite struct { +	suite.Suite +	config               *config.Config +	log                  *logrus.Logger +	testAccountLocal     *model.Account +	testApplication      *model.Application +	testToken            oauth2.TokenInfo +	mockOauthServer      *oauth.MockServer +	mockStorage          *storage.MockStorage +	mediaHandler         media.MediaHandler +	db                   db.DB +	accountModule        *accountModule +	newUserFormHappyPath url.Values +} + +/* +	TEST INFRASTRUCTURE +*/ + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *AccountUpdateTestSuite) SetupSuite() { +	// some of our subsequent entities need a log so create this here +	log := logrus.New() +	log.SetLevel(logrus.TraceLevel) +	suite.log = log + +	suite.testAccountLocal = &model.Account{ +		ID:       uuid.NewString(), +		Username: "test_user", +	} + +	// can use this test application throughout +	suite.testApplication = &model.Application{ +		ID:           "weeweeeeeeeeeeeeee", +		Name:         "a test application", +		Website:      "https://some-application-website.com", +		RedirectURI:  "http://localhost:8080", +		ClientID:     "a-known-client-id", +		ClientSecret: "some-secret", +		Scopes:       "read", +		VapidKey:     "aaaaaa-aaaaaaaa-aaaaaaaaaaa", +	} + +	// can use this test token throughout +	suite.testToken = &oauthmodels.Token{ +		ClientID:      "a-known-client-id", +		RedirectURI:   "http://localhost:8080", +		Scope:         "read", +		Code:          "123456789", +		CodeCreateAt:  time.Now(), +		CodeExpiresIn: time.Duration(10 * time.Minute), +	} + +	// Direct config to local postgres instance +	c := config.Empty() +	c.Protocol = "http" +	c.Host = "localhost" +	c.DBConfig = &config.DBConfig{ +		Type:            "postgres", +		Address:         "localhost", +		Port:            5432, +		User:            "postgres", +		Password:        "postgres", +		Database:        "postgres", +		ApplicationName: "gotosocial", +	} +	c.MediaConfig = &config.MediaConfig{ +		MaxImageSize: 2 << 20, +	} +	c.StorageConfig = &config.StorageConfig{ +		Backend:       "local", +		BasePath:      "/tmp", +		ServeProtocol: "http", +		ServeHost:     "localhost", +		ServeBasePath: "/fileserver/media", +	} +	suite.config = c + +	// use an actual database for this, because it's just easier than mocking one out +	database, err := db.New(context.Background(), c, log) +	if err != nil { +		suite.FailNow(err.Error()) +	} +	suite.db = database + +	// we need to mock the oauth server because account creation needs it to create a new token +	suite.mockOauthServer = &oauth.MockServer{} +	suite.mockOauthServer.On("GenerateUserAccessToken", suite.testToken, suite.testApplication.ClientSecret, mock.AnythingOfType("string")).Run(func(args mock.Arguments) { +		l := suite.log.WithField("func", "GenerateUserAccessToken") +		token := args.Get(0).(oauth2.TokenInfo) +		l.Infof("received token %+v", token) +		clientSecret := args.Get(1).(string) +		l.Infof("received clientSecret %+v", clientSecret) +		userID := args.Get(2).(string) +		l.Infof("received userID %+v", userID) +	}).Return(&models.Token{ +		Code: "we're authorized now!", +	}, nil) + +	suite.mockStorage = &storage.MockStorage{} +	// We don't need storage to do anything for these tests, so just simulate a success and do nothing -- we won't need to return anything from storage +	suite.mockStorage.On("StoreFileAt", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8")).Return(nil) + +	// set a media handler because some handlers (eg update credentials) need to upload media (new header/avatar) +	suite.mediaHandler = media.New(suite.config, suite.db, suite.mockStorage, log) + +	// and finally here's the thing we're actually testing! +	suite.accountModule = New(suite.config, suite.db, suite.mockOauthServer, suite.mediaHandler, suite.log).(*accountModule) +} + +func (suite *AccountUpdateTestSuite) TearDownSuite() { +	if err := suite.db.Stop(context.Background()); err != nil { +		logrus.Panicf("error closing db connection: %s", err) +	} +} + +// SetupTest creates a db connection and creates necessary tables before each test +func (suite *AccountUpdateTestSuite) SetupTest() { +	// create all the tables we might need in thie suite +	models := []interface{}{ +		&model.User{}, +		&model.Account{}, +		&model.Follow{}, +		&model.FollowRequest{}, +		&model.Status{}, +		&model.Application{}, +		&model.EmailDomainBlock{}, +		&model.MediaAttachment{}, +	} +	for _, m := range models { +		if err := suite.db.CreateTable(m); err != nil { +			logrus.Panicf("db connection error: %s", err) +		} +	} + +	// form to submit for happy path account create requests -- this will be changed inside tests so it's better to set it before each test +	suite.newUserFormHappyPath = url.Values{ +		"reason":    []string{"a very good reason that's at least 40 characters i swear"}, +		"username":  []string{"test_user"}, +		"email":     []string{"user@example.org"}, +		"password":  []string{"very-strong-password"}, +		"agreement": []string{"true"}, +		"locale":    []string{"en"}, +	} + +	// same with accounts config +	suite.config.AccountsConfig = &config.AccountsConfig{ +		OpenRegistration: true, +		RequireApproval:  true, +		ReasonRequired:   true, +	} +} + +// TearDownTest drops tables to make sure there's no data in the db +func (suite *AccountUpdateTestSuite) TearDownTest() { + +	// remove all the tables we might have used so it's clear for the next test +	models := []interface{}{ +		&model.User{}, +		&model.Account{}, +		&model.Follow{}, +		&model.FollowRequest{}, +		&model.Status{}, +		&model.Application{}, +		&model.EmailDomainBlock{}, +		&model.MediaAttachment{}, +	} +	for _, m := range models { +		if err := suite.db.DropTable(m); err != nil { +			logrus.Panicf("error dropping table: %s", err) +		} +	} +} + +/* +	ACTUAL TESTS +*/ + +/* +	TESTING: AccountUpdateCredentialsPATCHHandler +*/ + +func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() { + +	// put test local account in db +	err := suite.db.Put(suite.testAccountLocal) +	assert.NoError(suite.T(), err) + +	// attach avatar to request form +	avatarFile, err := os.Open("../../media/test/test-jpeg.jpg") +	assert.NoError(suite.T(), err) +	body := &bytes.Buffer{} +	writer := multipart.NewWriter(body) + +	avatarPart, err := writer.CreateFormFile("avatar", "test-jpeg.jpg") +	assert.NoError(suite.T(), err) + +	_, err = io.Copy(avatarPart, avatarFile) +	assert.NoError(suite.T(), err) + +	err = avatarFile.Close() +	assert.NoError(suite.T(), err) + +	// set display name to a new value +	displayNamePart, err := writer.CreateFormField("display_name") +	assert.NoError(suite.T(), err) + +	_, err = io.Copy(displayNamePart, bytes.NewBufferString("test_user_wohoah")) +	assert.NoError(suite.T(), err) + +	// set locked to true +	lockedPart, err := writer.CreateFormField("locked") +	assert.NoError(suite.T(), err) + +	_, err = io.Copy(lockedPart, bytes.NewBufferString("true")) +	assert.NoError(suite.T(), err) + +	// close the request writer, the form is now prepared +	err = writer.Close() +	assert.NoError(suite.T(), err) + +	// setup +	recorder := httptest.NewRecorder() +	ctx, _ := gin.CreateTestContext(recorder) +	ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccountLocal) +	ctx.Set(oauth.SessionAuthorizedToken, suite.testToken) +	ctx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:8080/%s", updateCredentialsPath), body) // the endpoint we're hitting +	ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) +	suite.accountModule.accountUpdateCredentialsPATCHHandler(ctx) + +	// check response + +	// 1. we should have OK because our request was valid +	suite.EqualValues(http.StatusOK, recorder.Code) + +	// 2. we should have an error message in the result body +	result := recorder.Result() +	defer result.Body.Close() +	// TODO: implement proper checks here +	// +	// b, err := ioutil.ReadAll(result.Body) +	// assert.NoError(suite.T(), err) +	// assert.Equal(suite.T(), `{"error":"not authorized"}`, string(b)) +} + +func TestAccountUpdateTestSuite(t *testing.T) { +	suite.Run(t, new(AccountUpdateTestSuite)) +} diff --git a/internal/apimodule/account/accountverify.go b/internal/apimodule/account/accountverify.go new file mode 100644 index 000000000..fe8d24b22 --- /dev/null +++ b/internal/apimodule/account/accountverify.go @@ -0,0 +1,50 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// accountVerifyGETHandler serves a user's account details to them IF they reached this +// handler while in possession of a valid token, according to the oauth middleware. +// It should be served as a GET at /api/v1/accounts/verify_credentials +func (m *accountModule) accountVerifyGETHandler(c *gin.Context) { +	l := m.log.WithField("func", "accountVerifyGETHandler") +	authed, err := oauth.MustAuth(c, true, false, false, true) +	if err != nil { +		l.Debugf("couldn't auth: %s", err) +		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) +		return +	} + +	l.Tracef("retrieved account %+v, converting to mastosensitive...", authed.Account.ID) +	acctSensitive, err := m.db.AccountToMastoSensitive(authed.Account) +	if err != nil { +		l.Tracef("could not convert account into mastosensitive account: %s", err) +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		return +	} + +	l.Tracef("conversion successful, returning OK and mastosensitive account %+v", acctSensitive) +	c.JSON(http.StatusOK, acctSensitive) +} diff --git a/internal/apimodule/account/accountverify_test.go b/internal/apimodule/account/accountverify_test.go new file mode 100644 index 000000000..223a0c145 --- /dev/null +++ b/internal/apimodule/account/accountverify_test.go @@ -0,0 +1,19 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package account diff --git a/internal/apimodule/apimodule.go b/internal/apimodule/apimodule.go new file mode 100644 index 000000000..52275c6df --- /dev/null +++ b/internal/apimodule/apimodule.go @@ -0,0 +1,33 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +// Package apimodule is basically a wrapper for a lot of modules (in subdirectories) that satisfy the ClientAPIModule interface. +package apimodule + +import ( +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/router" +) + +// ClientAPIModule represents a chunk of code (usually contained in a single package) that adds a set +// of functionalities and side effects to a router, by mapping routes and handlers onto it--in other words, a REST API ;) +// A ClientAPIMpdule corresponds roughly to one main path of the gotosocial REST api, for example /api/v1/accounts/ or /oauth/ +type ClientAPIModule interface { +	Route(s router.Router) error +	CreateTables(db db.DB) error +} diff --git a/internal/apimodule/app/app.go b/internal/apimodule/app/app.go new file mode 100644 index 000000000..534f4cd3e --- /dev/null +++ b/internal/apimodule/app/app.go @@ -0,0 +1,71 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package app + +import ( +	"fmt" +	"net/http" + +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/apimodule" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/router" +) + +const appsPath = "/api/v1/apps" + +type appModule struct { +	server oauth.Server +	db     db.DB +	log    *logrus.Logger +} + +// New returns a new auth module +func New(srv oauth.Server, db db.DB, log *logrus.Logger) apimodule.ClientAPIModule { +	return &appModule{ +		server: srv, +		db:     db, +		log:    log, +	} +} + +// Route satisfies the RESTAPIModule interface +func (m *appModule) Route(s router.Router) error { +	s.AttachHandler(http.MethodPost, appsPath, m.appsPOSTHandler) +	return nil +} + +func (m *appModule) CreateTables(db db.DB) error { +	models := []interface{}{ +		&oauth.Client{}, +		&oauth.Token{}, +		&model.User{}, +		&model.Account{}, +		&model.Application{}, +	} + +	for _, m := range models { +		if err := db.CreateTable(m); err != nil { +			return fmt.Errorf("error creating table: %s", err) +		} +	} +	return nil +} diff --git a/internal/apimodule/app/app_test.go b/internal/apimodule/app/app_test.go new file mode 100644 index 000000000..d45b04e74 --- /dev/null +++ b/internal/apimodule/app/app_test.go @@ -0,0 +1,21 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package app + +// TODO: write tests diff --git a/internal/apimodule/app/appcreate.go b/internal/apimodule/app/appcreate.go new file mode 100644 index 000000000..cd5aff701 --- /dev/null +++ b/internal/apimodule/app/appcreate.go @@ -0,0 +1,113 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package app + +import ( +	"fmt" +	"net/http" + +	"github.com/gin-gonic/gin" +	"github.com/google/uuid" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/pkg/mastotypes" +) + +// appsPOSTHandler should be served at https://example.org/api/v1/apps +// It is equivalent to: https://docs.joinmastodon.org/methods/apps/ +func (m *appModule) appsPOSTHandler(c *gin.Context) { +	l := m.log.WithField("func", "AppsPOSTHandler") +	l.Trace("entering AppsPOSTHandler") + +	form := &mastotypes.ApplicationPOSTRequest{} +	if err := c.ShouldBind(form); err != nil { +		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) +		return +	} + +	// permitted length for most fields +	permittedLength := 64 +	// redirect can be a bit bigger because we probably need to encode data in the redirect uri +	permittedRedirect := 256 + +	// check lengths of fields before proceeding so the user can't spam huge entries into the database +	if len(form.ClientName) > permittedLength { +		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", permittedLength)}) +		return +	} +	if len(form.Website) > permittedLength { +		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", permittedLength)}) +		return +	} +	if len(form.RedirectURIs) > permittedRedirect { +		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", permittedRedirect)}) +		return +	} +	if len(form.Scopes) > permittedLength { +		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", permittedLength)}) +		return +	} + +	// set default 'read' for scopes if it's not set, this follows the default of the mastodon api https://docs.joinmastodon.org/methods/apps/ +	var scopes string +	if form.Scopes == "" { +		scopes = "read" +	} else { +		scopes = form.Scopes +	} + +	// generate new IDs for this application and its associated client +	clientID := uuid.NewString() +	clientSecret := uuid.NewString() +	vapidKey := uuid.NewString() + +	// generate the application to put in the database +	app := &model.Application{ +		Name:         form.ClientName, +		Website:      form.Website, +		RedirectURI:  form.RedirectURIs, +		ClientID:     clientID, +		ClientSecret: clientSecret, +		Scopes:       scopes, +		VapidKey:     vapidKey, +	} + +	// chuck it in the db +	if err := m.db.Put(app); err != nil { +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		return +	} + +	// now we need to model an oauth client from the application that the oauth library can use +	oc := &oauth.Client{ +		ID:     clientID, +		Secret: clientSecret, +		Domain: form.RedirectURIs, +		UserID: "", // This client isn't yet associated with a specific user,  it's just an app client right now +	} + +	// chuck it in the db +	if err := m.db.Put(oc); err != nil { +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		return +	} + +	// done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/ +	c.JSON(http.StatusOK, app.ToMasto()) +} diff --git a/internal/apimodule/auth/README.md b/internal/apimodule/auth/README.md new file mode 100644 index 000000000..96b2443c1 --- /dev/null +++ b/internal/apimodule/auth/README.md @@ -0,0 +1,5 @@ +# auth + +This package provides uses the [GoToSocial oauth2](https://github.com/gotosocial/oauth2) module (forked from [go-oauth2](https://github.com/go-oauth2/oauth2)) to provide [oauth2](https://www.oauth.com/) functionality to the GoToSocial client API. + +It also provides a handler/middleware for attaching to the Gin engine for validating authenticated users. diff --git a/internal/apimodule/auth/auth.go b/internal/apimodule/auth/auth.go new file mode 100644 index 000000000..3a85a4364 --- /dev/null +++ b/internal/apimodule/auth/auth.go @@ -0,0 +1,89 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +// Package auth is a module that provides oauth functionality to a router. +// It adds the following paths: +//    /auth/sign_in +//    /oauth/token +//    /oauth/authorize +// It also includes the oauthTokenMiddleware, which can be attached to a router to authenticate every request by Bearer token. +package auth + +import ( +	"fmt" +	"net/http" + +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/apimodule" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( +	authSignInPath     = "/auth/sign_in" +	oauthTokenPath     = "/oauth/token" +	oauthAuthorizePath = "/oauth/authorize" +) + +type authModule struct { +	server oauth.Server +	db     db.DB +	log    *logrus.Logger +} + +// New returns a new auth module +func New(srv oauth.Server, db db.DB, log *logrus.Logger) apimodule.ClientAPIModule { +	return &authModule{ +		server: srv, +		db:     db, +		log:    log, +	} +} + +// Route satisfies the RESTAPIModule interface +func (m *authModule) Route(s router.Router) error { +	s.AttachHandler(http.MethodGet, authSignInPath, m.signInGETHandler) +	s.AttachHandler(http.MethodPost, authSignInPath, m.signInPOSTHandler) + +	s.AttachHandler(http.MethodPost, oauthTokenPath, m.tokenPOSTHandler) + +	s.AttachHandler(http.MethodGet, oauthAuthorizePath, m.authorizeGETHandler) +	s.AttachHandler(http.MethodPost, oauthAuthorizePath, m.authorizePOSTHandler) + +	s.AttachMiddleware(m.oauthTokenMiddleware) +	return nil +} + +func (m *authModule) CreateTables(db db.DB) error { +	models := []interface{}{ +		&oauth.Client{}, +		&oauth.Token{}, +		&model.User{}, +		&model.Account{}, +		&model.Application{}, +	} + +	for _, m := range models { +		if err := db.CreateTable(m); err != nil { +			return fmt.Errorf("error creating table: %s", err) +		} +	} +	return nil +} diff --git a/internal/apimodule/auth/auth_test.go b/internal/apimodule/auth/auth_test.go new file mode 100644 index 000000000..0ec9b4a41 --- /dev/null +++ b/internal/apimodule/auth/auth_test.go @@ -0,0 +1,189 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package auth + +import ( +	"context" +	"fmt" +	"testing" +	"time" + +	"github.com/google/uuid" +	"github.com/sirupsen/logrus" +	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +	"github.com/superseriousbusiness/gotosocial/internal/router" +	"golang.org/x/crypto/bcrypt" +) + +type AuthTestSuite struct { +	suite.Suite +	oauthServer     oauth.Server +	db              db.DB +	testAccount     *model.Account +	testApplication *model.Application +	testUser        *model.User +	testClient      *oauth.Client +	config          *config.Config +} + +// SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout +func (suite *AuthTestSuite) SetupSuite() { +	c := config.Empty() +	// we're running on localhost without https so set the protocol to http +	c.Protocol = "http" +	// just for testing +	c.Host = "localhost:8080" +	// 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.TemplateConfig.BaseDir = "../../../web/template/" +	c.DBConfig = &config.DBConfig{ +		Type:            "postgres", +		Address:         "localhost", +		Port:            5432, +		User:            "postgres", +		Password:        "postgres", +		Database:        "postgres", +		ApplicationName: "gotosocial", +	} +	suite.config = c + +	encryptedPassword, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) +	if err != nil { +		logrus.Panicf("error encrypting user pass: %s", err) +	} + +	acctID := uuid.NewString() + +	suite.testAccount = &model.Account{ +		ID:       acctID, +		Username: "test_user", +	} +	suite.testUser = &model.User{ +		EncryptedPassword: string(encryptedPassword), +		Email:             "user@example.org", +		AccountID:         acctID, +	} +	suite.testClient = &oauth.Client{ +		ID:     "a-known-client-id", +		Secret: "some-secret", +		Domain: fmt.Sprintf("%s://%s", c.Protocol, c.Host), +	} +	suite.testApplication = &model.Application{ +		Name:         "a test application", +		Website:      "https://some-application-website.com", +		RedirectURI:  "http://localhost:8080", +		ClientID:     "a-known-client-id", +		ClientSecret: "some-secret", +		Scopes:       "read", +		VapidKey:     uuid.NewString(), +	} +} + +// SetupTest creates a postgres connection and creates the oauth_clients table before each test +func (suite *AuthTestSuite) SetupTest() { + +	log := logrus.New() +	log.SetLevel(logrus.TraceLevel) +	db, err := db.New(context.Background(), suite.config, log) +	if err != nil { +		logrus.Panicf("error creating database connection: %s", err) +	} + +	suite.db = db + +	models := []interface{}{ +		&oauth.Client{}, +		&oauth.Token{}, +		&model.User{}, +		&model.Account{}, +		&model.Application{}, +	} + +	for _, m := range models { +		if err := suite.db.CreateTable(m); err != nil { +			logrus.Panicf("db connection error: %s", err) +		} +	} + +	suite.oauthServer = oauth.New(suite.db, log) + +	if err := suite.db.Put(suite.testAccount); err != nil { +		logrus.Panicf("could not insert test account into db: %s", err) +	} +	if err := suite.db.Put(suite.testUser); err != nil { +		logrus.Panicf("could not insert test user into db: %s", err) +	} +	if err := suite.db.Put(suite.testClient); err != nil { +		logrus.Panicf("could not insert test client into db: %s", err) +	} +	if err := suite.db.Put(suite.testApplication); err != nil { +		logrus.Panicf("could not insert test application into db: %s", err) +	} + +} + +// TearDownTest drops the oauth_clients table and closes the pg connection after each test +func (suite *AuthTestSuite) TearDownTest() { +	models := []interface{}{ +		&oauth.Client{}, +		&oauth.Token{}, +		&model.User{}, +		&model.Account{}, +		&model.Application{}, +	} +	for _, m := range models { +		if err := suite.db.DropTable(m); err != nil { +			logrus.Panicf("error dropping table: %s", err) +		} +	} +	if err := suite.db.Stop(context.Background()); err != nil { +		logrus.Panicf("error closing db connection: %s", err) +	} +	suite.db = nil +} + +func (suite *AuthTestSuite) TestAPIInitialize() { +	log := logrus.New() +	log.SetLevel(logrus.TraceLevel) + +	r, err := router.New(suite.config, log) +	if err != nil { +		suite.FailNow(fmt.Sprintf("error mapping routes onto router: %s", err)) +	} + +	api := New(suite.oauthServer, suite.db, log) +	if err := api.Route(r); err != nil { +		suite.FailNow(fmt.Sprintf("error mapping routes onto router: %s", err)) +	} + +	r.Start() +	time.Sleep(60 * time.Second) +	if err := r.Stop(context.Background()); err != nil { +		suite.FailNow(fmt.Sprintf("error stopping router: %s", err)) +	} +} + +func TestAuthTestSuite(t *testing.T) { +	suite.Run(t, new(AuthTestSuite)) +} diff --git a/internal/apimodule/auth/authorize.go b/internal/apimodule/auth/authorize.go new file mode 100644 index 000000000..4a27cc20e --- /dev/null +++ b/internal/apimodule/auth/authorize.go @@ -0,0 +1,204 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package auth + +import ( +	"errors" +	"fmt" +	"net/http" +	"net/url" + +	"github.com/gin-contrib/sessions" +	"github.com/gin-gonic/gin" +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/pkg/mastotypes" +) + +// 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 (m *authModule) authorizeGETHandler(c *gin.Context) { +	l := m.log.WithField("func", "AuthorizeGETHandler") +	s := sessions.Default(c) + +	// 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. +	userID, ok := s.Get("userid").(string) +	if !ok || userID == "" { +		l.Trace("userid was empty, parsing form then redirecting to sign in page") +		if err := parseAuthForm(c, l); err != nil { +			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +		} else { +			c.Redirect(http.StatusFound, authSignInPath) +		} +		return +	} + +	// We can use the client_id on the session to retrieve info about the app associated with the client_id +	clientID, ok := s.Get("client_id").(string) +	if !ok || clientID == "" { +		c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session"}) +		return +	} +	app := &model.Application{ +		ClientID: clientID, +	} +	if err := m.db.GetWhere("client_id", app.ClientID, app); err != nil { +		c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)}) +		return +	} + +	// we can also use the userid of the user to fetch their username from the db to greet them nicely <3 +	user := &model.User{ +		ID: userID, +	} +	if err := m.db.GetByID(user.ID, user); err != nil { +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		return +	} + +	acct := &model.Account{ +		ID: user.AccountID, +	} + +	if err := m.db.GetByID(acct.ID, acct); err != nil { +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		return +	} + +	// Finally we should also get the redirect and scope of this particular request, as stored in the session. +	redirect, ok := s.Get("redirect_uri").(string) +	if !ok || redirect == "" { +		c.JSON(http.StatusInternalServerError, gin.H{"error": "no redirect_uri found in session"}) +		return +	} +	scope, ok := s.Get("scope").(string) +	if !ok || scope == "" { +		c.JSON(http.StatusInternalServerError, gin.H{"error": "no scope found in session"}) +		return +	} + +	// the authorize template will display a form to the user where they can get some information +	// about the app that's trying to authorize, and the scope of the request. +	// They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler +	l.Trace("serving authorize html") +	c.HTML(http.StatusOK, "authorize.tmpl", gin.H{ +		"appname":    app.Name, +		"appwebsite": app.Website, +		"redirect":   redirect, +		"scope":      scope, +		"user":       acct.Username, +	}) +} + +// authorizePOSTHandler should be served as POST at https://example.org/oauth/authorize +// At this point we assume that the user has A) logged in and B) accepted that the app should act for them, +// so we should proceed with the authentication flow and generate an oauth token for them if we can. +// See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user +func (m *authModule) 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. +	forceLogin, ok := s.Get("force_login").(string) +	if !ok { +		c.JSON(http.StatusBadRequest, gin.H{"error": "session missing force_login"}) +		return +	} +	responseType, ok := s.Get("response_type").(string) +	if !ok || responseType == "" { +		c.JSON(http.StatusBadRequest, gin.H{"error": "session missing response_type"}) +		return +	} +	clientID, ok := s.Get("client_id").(string) +	if !ok || clientID == "" { +		c.JSON(http.StatusBadRequest, gin.H{"error": "session missing client_id"}) +		return +	} +	redirectURI, ok := s.Get("redirect_uri").(string) +	if !ok || redirectURI == "" { +		c.JSON(http.StatusBadRequest, gin.H{"error": "session missing redirect_uri"}) +		return +	} +	scope, ok := s.Get("scope").(string) +	if !ok { +		c.JSON(http.StatusBadRequest, gin.H{"error": "session missing scope"}) +		return +	} +	userID, ok := s.Get("userid").(string) +	if !ok { +		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() + +	// now set the values on the request +	values := url.Values{} +	values.Set("force_login", forceLogin) +	values.Set("response_type", responseType) +	values.Set("client_id", clientID) +	values.Set("redirect_uri", redirectURI) +	values.Set("scope", scope) +	values.Set("userid", userID) +	c.Request.Form = values +	l.Tracef("values on request set to %+v", c.Request.Form) + +	// and proceed with authorization using the oauth2 library +	if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil { +		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +	} +} + +// parseAuthForm parses the OAuthAuthorize form in the gin context, and stores +// the values in the form into the session. +func parseAuthForm(c *gin.Context, l *logrus.Entry) error { +	s := sessions.Default(c) + +	// first make sure they've filled out the authorize form with the required values +	form := &mastotypes.OAuthAuthorize{} +	if err := c.ShouldBind(form); err != nil { +		return err +	} +	l.Tracef("parsed form: %+v", form) + +	// these fields are *required* so check 'em +	if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" { +		return errors.New("missing one of: response_type, client_id or redirect_uri") +	} + +	// set default scope to read +	if form.Scope == "" { +		form.Scope = "read" +	} + +	// 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) +	return s.Save() +} diff --git a/internal/apimodule/auth/middleware.go b/internal/apimodule/auth/middleware.go new file mode 100644 index 000000000..32fc24d52 --- /dev/null +++ b/internal/apimodule/auth/middleware.go @@ -0,0 +1,76 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package auth + +import ( +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// oauthTokenMiddleware checks if the client has presented a valid oauth Bearer token. +// If so, it will check the User that the token belongs to, and set that in the context of +// the request. Then, it will look up the account for that user, and set that in the request too. +// If user or account can't be found, then the handler won't *fail*, in case the server wants to allow +// public requests that don't have a Bearer token set (eg., for public instance information and so on). +func (m *authModule) oauthTokenMiddleware(c *gin.Context) { +	l := m.log.WithField("func", "ValidatePassword") +	l.Trace("entering OauthTokenMiddleware") + +	ti, err := m.server.ValidationBearerToken(c.Request) +	if err != nil { +		l.Trace("no valid token presented: continuing with unauthenticated request") +		return +	} +	c.Set(oauth.SessionAuthorizedToken, ti) +	l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedToken, ti) + +	// check for user-level token +	if uid := ti.GetUserID(); uid != "" { +		l.Tracef("authenticated user %s with bearer token, scope is %s", uid, ti.GetScope()) + +		// fetch user's and account for this user id +		user := &model.User{} +		if err := m.db.GetByID(uid, user); err != nil || user == nil { +			l.Warnf("no user found for validated uid %s", uid) +			return +		} +		c.Set(oauth.SessionAuthorizedUser, user) +		l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedUser, user) + +		acct := &model.Account{} +		if err := m.db.GetByID(user.AccountID, acct); err != nil || acct == nil { +			l.Warnf("no account found for validated user %s", uid) +			return +		} +		c.Set(oauth.SessionAuthorizedAccount, acct) +		l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedAccount, acct) +	} + +	// check for application token +	if cid := ti.GetClientID(); cid != "" { +		l.Tracef("authenticated client %s with bearer token, scope is %s", cid, ti.GetScope()) +		app := &model.Application{} +		if err := m.db.GetWhere("client_id", cid, app); err != nil { +			l.Tracef("no app found for client %s", cid) +		} +		c.Set(oauth.SessionAuthorizedApplication, app) +		l.Tracef("set gin context %s to %+v", oauth.SessionAuthorizedApplication, app) +	} +} diff --git a/internal/apimodule/auth/signin.go b/internal/apimodule/auth/signin.go new file mode 100644 index 000000000..34146cbfc --- /dev/null +++ b/internal/apimodule/auth/signin.go @@ -0,0 +1,115 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package auth + +import ( +	"errors" +	"net/http" + +	"github.com/gin-contrib/sessions" +	"github.com/gin-gonic/gin" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"golang.org/x/crypto/bcrypt" +) + +type login struct { +	Email    string `form:"username"` +	Password string `form:"password"` +} + +// signInGETHandler 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 form will then POST to the sign in page, which will be handled by SignInPOSTHandler +func (m *authModule) signInGETHandler(c *gin.Context) { +	m.log.WithField("func", "SignInGETHandler").Trace("serving sign in html") +	c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{}) +} + +// signInPOSTHandler 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 (m *authModule) signInPOSTHandler(c *gin.Context) { +	l := m.log.WithField("func", "SignInPOSTHandler") +	s := sessions.Default(c) +	form := &login{} +	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 := m.validatePassword(form.Email, form.Password) +	if err != nil { +		c.String(http.StatusForbidden, err.Error()) +		return +	} + +	s.Set("userid", userid) +	if err := s.Save(); err != nil { +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +		return +	} + +	l.Trace("redirecting to auth page") +	c.Redirect(http.StatusFound, oauthAuthorizePath) +} + +// validatePassword takes 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 (m *authModule) validatePassword(email string, password string) (userid string, err error) { +	l := m.log.WithField("func", "ValidatePassword") + +	// 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 := &model.User{} + +	if err := m.db.GetWhere("email", email, gtsUser); err != nil { +		l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) +		return incorrectPassword() +	} + +	// make sure a password is actually set and bail if not +	if gtsUser.EncryptedPassword == "" { +		l.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email) +		return incorrectPassword() +	} + +	// compare the provided password with the encrypted one from the db, bail if they don't match +	if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil { +		l.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err) +		return incorrectPassword() +	} + +	// 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 +} + +// incorrectPassword is just a little helper function to use in the ValidatePassword function +func incorrectPassword() (string, error) { +	return "", errors.New("password/email combination was incorrect") +} diff --git a/internal/apimodule/auth/token.go b/internal/apimodule/auth/token.go new file mode 100644 index 000000000..1e54b6ab3 --- /dev/null +++ b/internal/apimodule/auth/token.go @@ -0,0 +1,36 @@ +/* +   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 <http://www.gnu.org/licenses/>. +*/ + +package auth + +import ( +	"net/http" + +	"github.com/gin-gonic/gin" +) + +// tokenPOSTHandler 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 (m *authModule) tokenPOSTHandler(c *gin.Context) { +	l := m.log.WithField("func", "TokenPOSTHandler") +	l.Trace("entered TokenPOSTHandler") +	if err := m.server.HandleTokenRequest(c.Writer, c.Request); err != nil { +		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +	} +} diff --git a/internal/apimodule/fileserver/fileserver.go b/internal/apimodule/fileserver/fileserver.go new file mode 100644 index 000000000..bbafff76f --- /dev/null +++ b/internal/apimodule/fileserver/fileserver.go @@ -0,0 +1,63 @@ +package fileserver + +import ( +	"fmt" + +	"github.com/sirupsen/logrus" +	"github.com/superseriousbusiness/gotosocial/internal/apimodule" +	"github.com/superseriousbusiness/gotosocial/internal/config" +	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/db/model" +	"github.com/superseriousbusiness/gotosocial/internal/router" +	"github.com/superseriousbusiness/gotosocial/internal/storage" +) + +// fileServer implements the RESTAPIModule interface. +// The goal here is to serve requested media files if the gotosocial server is configured to use local storage. +type fileServer struct { +	config      *config.Config +	db          db.DB +	storage     storage.Storage +	log         *logrus.Logger +	storageBase string +} + +// New returns a new fileServer module +func New(config *config.Config, db db.DB, storage storage.Storage, log *logrus.Logger) apimodule.ClientAPIModule { + +	storageBase := config.StorageConfig.BasePath // TODO: do this properly + +	return &fileServer{ +		config:      config, +		db:          db, +		storage:     storage, +		log:         log, +		storageBase: storageBase, +	} +} + +// Route satisfies the RESTAPIModule interface +func (m *fileServer) Route(s router.Router) error { +	// s.AttachHandler(http.MethodPost, appsPath, m.appsPOSTHandler) +	return nil +} + +func (m *fileServer) CreateTables(db db.DB) error { +	models := []interface{}{ +		&model.User{}, +		&model.Account{}, +		&model.Follow{}, +		&model.FollowRequest{}, +		&model.Status{}, +		&model.Application{}, +		&model.EmailDomainBlock{}, +		&model.MediaAttachment{}, +	} + +	for _, m := range models { +		if err := db.CreateTable(m); err != nil { +			return fmt.Errorf("error creating table: %s", err) +		} +	} +	return nil +} diff --git a/internal/apimodule/mock_ClientAPIModule.go b/internal/apimodule/mock_ClientAPIModule.go new file mode 100644 index 000000000..85c7b6ac6 --- /dev/null +++ b/internal/apimodule/mock_ClientAPIModule.go @@ -0,0 +1,27 @@ +// Code generated by mockery v2.7.4. DO NOT EDIT. + +package apimodule + +import ( +	mock "github.com/stretchr/testify/mock" +	router "github.com/superseriousbusiness/gotosocial/internal/router" +) + +// MockClientAPIModule is an autogenerated mock type for the ClientAPIModule type +type MockClientAPIModule struct { +	mock.Mock +} + +// Route provides a mock function with given fields: s +func (_m *MockClientAPIModule) Route(s router.Router) error { +	ret := _m.Called(s) + +	var r0 error +	if rf, ok := ret.Get(0).(func(router.Router) error); ok { +		r0 = rf(s) +	} else { +		r0 = ret.Error(0) +	} + +	return r0 +}  | 
