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
This commit is contained in:
Tobi Smethurst
2021-04-01 20:46:45 +02:00
committed by GitHub
parent aa9ce272dc
commit 71a49e2b43
94 changed files with 6585 additions and 955 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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())
}

View File

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

View File

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

View File

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

View File

@ -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()
}

View File

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

View File

@ -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")
}

View File

@ -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()})
}
}

View File

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

View File

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