diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 77a150a9a..72c543a3a 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -3362,6 +3362,51 @@ paths: summary: See public statuses/posts that your instance is aware of. tags: - timelines + /api/v1/user/password_change: + post: + consumes: + - application/json + - application/xml + - application/x-www-form-urlencoded + description: |- + The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'. + The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'. + operationId: userPasswordChange + parameters: + - description: User's previous password. + in: formData + name: old_password + required: true + type: string + x-go-name: OldPassword + - description: |- + Desired new password. + If the password does not have high enough entropy, it will be rejected. + See https://github.com/wagslane/go-password-validator + in: formData + name: new_password + required: true + type: string + x-go-name: NewPassword + produces: + - application/json + responses: + "200": + description: Change successful + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "500": + description: internal error + security: + - OAuth2 Bearer: + - write:user + summary: Change the password of authenticated user. + tags: + - user /users/{username}/statuses/{status}/replies: get: description: |- @@ -3437,12 +3482,14 @@ securityDefinitions: read:search: grant read access to searches read:statuses: grants read access to statuses read:streaming: grants read access to streaming api + read:user: grants read access to user-level info write: grants write access to everything write:accounts: grants write access to accounts write:blocks: grants write access to blocks write:follows: grants write access to follows write:media: grants write access to media write:statuses: grants write access to statuses + write:user: grants write access to user-level info tokenUrl: https://example.org/oauth/token type: oauth2 swagger: "2.0" diff --git a/docs/swagger.go b/docs/swagger.go index a30700e4d..422a4ac57 100644 --- a/docs/swagger.go +++ b/docs/swagger.go @@ -40,12 +40,14 @@ // read:search: grant read access to searches // read:statuses: grants read access to statuses // read:streaming: grants read access to streaming api +// read:user: grants read access to user-level info // write: grants write access to everything // write:accounts: grants write access to accounts // write:blocks: grants write access to blocks // write:follows: grants write access to follows // write:media: grants write access to media // write:statuses: grants write access to statuses +// write:user: grants write access to user-level info // admin: grants admin access to everything // admin:accounts: grants admin access to accounts // OAuth2 Application: diff --git a/docs/user_guide/password_management.md b/docs/user_guide/password_management.md new file mode 100644 index 000000000..f6e2de1b2 --- /dev/null +++ b/docs/user_guide/password_management.md @@ -0,0 +1,19 @@ +# Password Management + +GoToSocial stores hashes of user passwords in its database using the secure [bcrypt](https://en.wikipedia.org/wiki/Bcrypt) function in the [Go standard libraries](https://pkg.go.dev/golang.org/x/crypto/bcrypt). + +This means that the plaintext value of your password is safe even if the database of your GoToSocial instance is compromised. It also means that your instance admin does not have access to your password. + +To check whether a password is sufficiently secure before accepting it, GoToSocial uses [this library](https://github.com/wagslane/go-password-validator) with entropy set to 60. This means that passwords like `password` are rejected, but something like `verylongandsecurepasswordhahaha` would be accepted, even without special characters/upper+lowercase etc. + +We recommend following the EFF's guidelines on [creating strong passwords](https://ssd.eff.org/en/module/creating-strong-passwords). + +## Change Your Password + +### API method + +If you are logged in (ie., you have a valid oauth token), you can change your password by making a POST request to `/api/v1/user/password_change`, using your token as authentication, and giving your old password and desired new password as parameters. Check the [API documentation](../api/swagger.md) for more details. + +## Reset Your Password + +todo diff --git a/internal/api/client/user/passwordchange.go b/internal/api/client/user/passwordchange.go new file mode 100644 index 000000000..581abe526 --- /dev/null +++ b/internal/api/client/user/passwordchange.go @@ -0,0 +1,97 @@ +/* + 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 . +*/ + +package user + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// PasswordChangePOSTHandler swagger:operation POST /api/v1/user/password_change userPasswordChange +// +// Change the password of authenticated user. +// +// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'. +// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'. +// +// --- +// tags: +// - user +// +// consumes: +// - application/json +// - application/xml +// - application/x-www-form-urlencoded +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - write:user +// +// responses: +// '200': +// description: Change successful +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '400': +// description: bad request +// '500': +// description: "internal error" +func (m *Module) PasswordChangePOSTHandler(c *gin.Context) { + l := logrus.WithField("func", "PasswordChangePOSTHandler") + + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + l.Debugf("error authing: %s", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + // First check this user/account is active. + if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { + l.Debugf("couldn't auth: %s", err) + c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) + return + } + + form := &model.PasswordChangeRequest{} + if err := c.ShouldBind(form); err != nil || form == nil || form.NewPassword == "" || form.OldPassword == "" { + if err != 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 + } + + if errWithCode := m.processor.UserChangePassword(c.Request.Context(), authed, form); errWithCode != nil { + l.Debugf("error changing user password: %s", errWithCode.Error()) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + c.Status(http.StatusOK) +} diff --git a/internal/api/client/user/passwordchange_test.go b/internal/api/client/user/passwordchange_test.go new file mode 100644 index 000000000..bdbeb3e42 --- /dev/null +++ b/internal/api/client/user/passwordchange_test.go @@ -0,0 +1,157 @@ +/* + 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 . +*/ + +package user_test + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/user" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "golang.org/x/crypto/bcrypt" +) + +type PasswordChangeTestSuite struct { + UserStandardTestSuite +} + +func (suite *PasswordChangeTestSuite) TestPasswordChangePOST() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", user.PasswordChangePath), nil) + ctx.Request.Form = url.Values{ + "old_password": {"password"}, + "new_password": {"peepeepoopoopassword"}, + } + suite.userModule.PasswordChangePOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + dbUser := >smodel.User{} + err := suite.db.GetByID(context.Background(), suite.testUsers["local_account_1"].ID, dbUser) + suite.NoError(err) + + // new password should pass + err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("peepeepoopoopassword")) + suite.NoError(err) + + // old password should fail + err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password")) + suite.EqualError(err, "crypto/bcrypt: hashedPassword is not the hash of the given password") +} + +func (suite *PasswordChangeTestSuite) TestPasswordMissingOldPassword() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", user.PasswordChangePath), nil) + ctx.Request.Form = url.Values{ + "new_password": {"peepeepoopoopassword"}, + } + suite.userModule.PasswordChangePOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusBadRequest, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Equal(`{"error":"missing one or more required form values"}`, string(b)) +} + +func (suite *PasswordChangeTestSuite) TestPasswordIncorrectOldPassword() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", user.PasswordChangePath), nil) + ctx.Request.Form = url.Values{ + "old_password": {"notright"}, + "new_password": {"peepeepoopoopassword"}, + } + suite.userModule.PasswordChangePOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusBadRequest, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Equal(`{"error":"bad request: old password did not match"}`, string(b)) +} + +func (suite *PasswordChangeTestSuite) TestPasswordWeakNewPassword() { + t := suite.testTokens["local_account_1"] + oauthToken := oauth.DBTokenToToken(t) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauthToken) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", user.PasswordChangePath), nil) + ctx.Request.Form = url.Values{ + "old_password": {"password"}, + "new_password": {"peepeepoopoo"}, + } + suite.userModule.PasswordChangePOSTHandler(ctx) + + // check response + suite.EqualValues(http.StatusBadRequest, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + suite.NoError(err) + suite.Equal(`{"error":"bad request: insecure password, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b)) +} + +func TestPasswordChangeTestSuite(t *testing.T) { + suite.Run(t, &PasswordChangeTestSuite{}) +} diff --git a/internal/api/client/user/user.go b/internal/api/client/user/user.go new file mode 100644 index 000000000..ff28a197f --- /dev/null +++ b/internal/api/client/user/user.go @@ -0,0 +1,55 @@ +/* + 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 . +*/ + +package user + +import ( + "net/http" + + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // BasePath is the base URI path for this module + BasePath = "/api/v1/user" + // PasswordChangePath is the path for POSTing a password change request. + PasswordChangePath = BasePath + "/password_change" +) + +// Module implements the ClientAPIModule interface +type Module struct { + config *config.Config + processor processing.Processor +} + +// New returns a new user module +func New(config *config.Config, processor processing.Processor) api.ClientModule { + return &Module{ + config: config, + processor: processor, + } +} + +// Route attaches all routes from this module to the given router +func (m *Module) Route(r router.Router) error { + r.AttachHandler(http.MethodPost, PasswordChangePath, m.PasswordChangePOSTHandler) + return nil +} diff --git a/internal/api/client/user/user_test.go b/internal/api/client/user/user_test.go new file mode 100644 index 000000000..02f10b5ae --- /dev/null +++ b/internal/api/client/user/user_test.go @@ -0,0 +1,73 @@ +/* + 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 . +*/ + +package user_test + +import ( + "git.iim.gay/grufwub/go-store/kv" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/user" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type UserStandardTestSuite struct { + suite.Suite + config *config.Config + db db.DB + tc typeutils.TypeConverter + federator federation.Federator + processor processing.Processor + storage *kv.KVStore + + testTokens map[string]*gtsmodel.Token + testClients map[string]*gtsmodel.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + + userModule *user.Module +} + +func (suite *UserStandardTestSuite) SetupTest() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() + testrig.InitTestLog() + suite.tc = testrig.NewTestTypeConverter(suite.db) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db), suite.storage) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) + suite.userModule = user.New(suite.config, suite.processor).(*user.Module) + testrig.StandardDBSetup(suite.db, suite.testAccounts) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *UserStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} diff --git a/internal/api/model/user.go b/internal/api/model/user.go new file mode 100644 index 000000000..3f639b5c3 --- /dev/null +++ b/internal/api/model/user.go @@ -0,0 +1,37 @@ +/* + 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 . +*/ + +package model + +// PasswordChangeRequest models user password change parameters. +// +// swagger:parameters userPasswordChange +type PasswordChangeRequest struct { + // User's previous password. + // + // in: formData + // required: true + OldPassword string `form:"old_password" json:"old_password" xml:"old_password" validation:"required"` + // Desired new password. + // If the password does not have high enough entropy, it will be rejected. + // See https://github.com/wagslane/go-password-validator + // + // in: formData + // required: true + NewPassword string `form:"new_password" json:"new_password" xml:"new_password" validation:"required"` +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 5732ad092..e61661dc9 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -39,6 +39,7 @@ import ( mediaProcessor "github.com/superseriousbusiness/gotosocial/internal/processing/media" "github.com/superseriousbusiness/gotosocial/internal/processing/status" "github.com/superseriousbusiness/gotosocial/internal/processing/streaming" + "github.com/superseriousbusiness/gotosocial/internal/processing/user" "github.com/superseriousbusiness/gotosocial/internal/stream" "github.com/superseriousbusiness/gotosocial/internal/timeline" "github.com/superseriousbusiness/gotosocial/internal/typeutils" @@ -173,6 +174,9 @@ type Processor interface { // OpenStreamForAccount opens a new stream for the given account, with the given stream type. OpenStreamForAccount(ctx context.Context, account *gtsmodel.Account, streamType string) (*stream.Stream, gtserror.WithCode) + // UserChangePassword changes the password for the given user, with the given form. + UserChangePassword(ctx context.Context, authed *oauth.Auth, form *apimodel.PasswordChangeRequest) gtserror.WithCode + /* FEDERATION API-FACING PROCESSING FUNCTIONS These functions are intended to be called when the federating client needs an immediate (ie., synchronous) reply @@ -247,6 +251,7 @@ type processor struct { statusProcessor status.Processor streamingProcessor streaming.Processor mediaProcessor mediaProcessor.Processor + userProcessor user.Processor } // NewProcessor returns a new Processor that uses the given federator @@ -259,6 +264,7 @@ func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator f accountProcessor := account.New(db, tc, mediaHandler, oauthServer, fromClientAPI, federator, config) adminProcessor := admin.New(db, tc, mediaHandler, fromClientAPI, config) mediaProcessor := mediaProcessor.New(db, tc, mediaHandler, storage, config) + userProcessor := user.New(db, config) return &processor{ fromClientAPI: fromClientAPI, @@ -279,6 +285,7 @@ func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator f statusProcessor: statusProcessor, streamingProcessor: streamingProcessor, mediaProcessor: mediaProcessor, + userProcessor: userProcessor, } } diff --git a/internal/processing/user.go b/internal/processing/user.go new file mode 100644 index 000000000..a5fca53dd --- /dev/null +++ b/internal/processing/user.go @@ -0,0 +1,31 @@ +/* + 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 . +*/ + +package processing + +import ( + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (p *processor) UserChangePassword(ctx context.Context, authed *oauth.Auth, form *apimodel.PasswordChangeRequest) gtserror.WithCode { + return p.userProcessor.ChangePassword(ctx, authed.User, form.OldPassword, form.NewPassword) +} diff --git a/internal/processing/user/changepassword.go b/internal/processing/user/changepassword.go new file mode 100644 index 000000000..3fe2c8d7c --- /dev/null +++ b/internal/processing/user/changepassword.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 . +*/ + +package user + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/validate" + "golang.org/x/crypto/bcrypt" +) + +func (p *processor) ChangePassword(ctx context.Context, user *gtsmodel.User, oldPassword string, newPassword string) gtserror.WithCode { + if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(oldPassword)); err != nil { + return gtserror.NewErrorBadRequest(err, "old password did not match") + } + + if err := validate.NewPassword(newPassword); err != nil { + return gtserror.NewErrorBadRequest(err, err.Error()) + } + + newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return gtserror.NewErrorInternalError(err, "error hashing password") + } + + user.EncryptedPassword = string(newPasswordHash) + if err := p.db.UpdateByPrimaryKey(ctx, user); err != nil { + return gtserror.NewErrorInternalError(err, "database error") + } + + return nil +} diff --git a/internal/processing/user/changepassword_test.go b/internal/processing/user/changepassword_test.go new file mode 100644 index 000000000..2687eae10 --- /dev/null +++ b/internal/processing/user/changepassword_test.go @@ -0,0 +1,74 @@ +/* + 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 . +*/ + +package user_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "golang.org/x/crypto/bcrypt" +) + +type ChangePasswordTestSuite struct { + UserStandardTestSuite +} + +func (suite *ChangePasswordTestSuite) TestChangePasswordOK() { + user := suite.testUsers["local_account_1"] + + errWithCode := suite.user.ChangePassword(context.Background(), user, "password", "verygoodnewpassword") + suite.NoError(errWithCode) + + err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte("verygoodnewpassword")) + suite.NoError(err) + + // get user from the db again + dbUser := >smodel.User{} + err = suite.db.GetByID(context.Background(), user.ID, dbUser) + suite.NoError(err) + + // check the password has changed + err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("verygoodnewpassword")) + suite.NoError(err) +} + +func (suite *ChangePasswordTestSuite) TestChangePasswordIncorrectOld() { + user := suite.testUsers["local_account_1"] + + errWithCode := suite.user.ChangePassword(context.Background(), user, "ooooopsydoooopsy", "verygoodnewpassword") + suite.EqualError(errWithCode, "crypto/bcrypt: hashedPassword is not the hash of the given password") + suite.Equal(http.StatusBadRequest, errWithCode.Code()) + suite.Equal("bad request: old password did not match", errWithCode.Safe()) +} + +func (suite *ChangePasswordTestSuite) TestChangePasswordWeakNew() { + user := suite.testUsers["local_account_1"] + + errWithCode := suite.user.ChangePassword(context.Background(), user, "password", "1234") + suite.EqualError(errWithCode, "insecure password, try including more special characters, using lowercase letters, using uppercase letters or using a longer password") + suite.Equal(http.StatusBadRequest, errWithCode.Code()) + suite.Equal("bad request: insecure password, try including more special characters, using lowercase letters, using uppercase letters or using a longer password", errWithCode.Safe()) +} + +func TestChangePasswordTestSuite(t *testing.T) { + suite.Run(t, &ChangePasswordTestSuite{}) +} diff --git a/internal/processing/user/user.go b/internal/processing/user/user.go new file mode 100644 index 000000000..c572becc2 --- /dev/null +++ b/internal/processing/user/user.go @@ -0,0 +1,48 @@ +/* + 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 . +*/ + +package user + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// Processor wraps a bunch of functions for processing user-level actions. +type Processor interface { + // ChangePassword changes the specified user's password from old => new, + // or returns an error if the new password is too weak, or the old password is incorrect. + ChangePassword(ctx context.Context, user *gtsmodel.User, oldPassword string, newPassword string) gtserror.WithCode +} + +type processor struct { + config *config.Config + db db.DB +} + +// New returns a new user processor +func New(db db.DB, config *config.Config) Processor { + return &processor{ + config: config, + db: db, + } +} diff --git a/internal/processing/user/user_test.go b/internal/processing/user/user_test.go new file mode 100644 index 000000000..4f18e03c2 --- /dev/null +++ b/internal/processing/user/user_test.go @@ -0,0 +1,52 @@ +/* + 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 . +*/ + +package user_test + +import ( + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/processing/user" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type UserStandardTestSuite struct { + suite.Suite + config *config.Config + db db.DB + + testUsers map[string]*gtsmodel.User + + user user.Processor +} + +func (suite *UserStandardTestSuite) SetupTest() { + testrig.InitTestLog() + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.testUsers = testrig.NewTestUsers() + suite.user = user.New(suite.db, suite.config) + + testrig.StandardDBSetup(suite.db, nil) +} + +func (suite *UserStandardTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +}