From b6fbdc66c1ce1ec61ebfb6fcc0351ea627a1d288 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 22 Feb 2023 16:05:26 +0100 Subject: [chore] Deinterface processor and subprocessors (#1501) * [chore] Deinterface processor and subprocessors * expose subprocessors via function calls * missing license header --- internal/processing/user/changepassword.go | 51 --------- internal/processing/user/changepassword_test.go | 92 ---------------- internal/processing/user/email.go | 137 ++++++++++++++++++++++++ internal/processing/user/email_test.go | 116 ++++++++++++++++++++ internal/processing/user/emailconfirm.go | 134 ----------------------- internal/processing/user/emailconfirm_test.go | 116 -------------------- internal/processing/user/password.go | 52 +++++++++ internal/processing/user/password_test.go | 92 ++++++++++++++++ internal/processing/user/user.go | 19 +--- 9 files changed, 399 insertions(+), 410 deletions(-) delete mode 100644 internal/processing/user/changepassword.go delete mode 100644 internal/processing/user/changepassword_test.go create mode 100644 internal/processing/user/email.go create mode 100644 internal/processing/user/email_test.go delete mode 100644 internal/processing/user/emailconfirm.go delete mode 100644 internal/processing/user/emailconfirm_test.go create mode 100644 internal/processing/user/password.go create mode 100644 internal/processing/user/password_test.go (limited to 'internal/processing/user') diff --git a/internal/processing/user/changepassword.go b/internal/processing/user/changepassword.go deleted file mode 100644 index 03b8c4525..000000000 --- a/internal/processing/user/changepassword.go +++ /dev/null @@ -1,51 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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.NewErrorUnauthorized(err, "old password was incorrect") - } - - 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.UpdateUser(ctx, user, "encrypted_password"); err != nil { - return gtserror.NewErrorInternalError(err) - } - - return nil -} diff --git a/internal/processing/user/changepassword_test.go b/internal/processing/user/changepassword_test.go deleted file mode 100644 index 74676b323..000000000 --- a/internal/processing/user/changepassword_test.go +++ /dev/null @@ -1,92 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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.StatusUnauthorized, errWithCode.Code()) - suite.Equal("Unauthorized: old password was incorrect", errWithCode.Safe()) - - // 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 not changed - err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password")) - suite.NoError(err) -} - -func (suite *ChangePasswordTestSuite) TestChangePasswordWeakNew() { - user := suite.testUsers["local_account_1"] - - errWithCode := suite.user.ChangePassword(context.Background(), user, "password", "1234") - suite.EqualError(errWithCode, "password is only 11% strength, 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: password is only 11% strength, try including more special characters, using lowercase letters, using uppercase letters or using a longer password", errWithCode.Safe()) - - // 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 not changed - err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password")) - suite.NoError(err) -} - -func TestChangePasswordTestSuite(t *testing.T) { - suite.Run(t, &ChangePasswordTestSuite{}) -} diff --git a/internal/processing/user/email.go b/internal/processing/user/email.go new file mode 100644 index 000000000..349e27f47 --- /dev/null +++ b/internal/processing/user/email.go @@ -0,0 +1,137 @@ +/* + GoToSocial + Copyright (C) 2021-2023 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" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/uris" +) + +var oneWeek = 168 * time.Hour + +// EmailSendConfirmation sends an email address confirmation request email to the given user. +func (p *Processor) EmailSendConfirmation(ctx context.Context, user *gtsmodel.User, username string) error { + if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email { + // user has already confirmed this email address, so there's nothing to do + return nil + } + + // We need a token and a link for the user to click on. + // We'll use a uuid as our token since it's basically impossible to guess. + // From the uuid package we use (which uses crypto/rand under the hood): + // Randomly generated UUIDs have 122 random bits. One's annual risk of being + // hit by a meteorite is estimated to be one chance in 17 billion, that + // means the probability is about 0.00000000006 (6 × 10−11), + // equivalent to the odds of creating a few tens of trillions of UUIDs in a + // year and having one duplicate. + confirmationToken := uuid.NewString() + confirmationLink := uris.GenerateURIForEmailConfirm(confirmationToken) + + // pull our instance entry from the database so we can greet the user nicely in the email + instance := >smodel.Instance{} + host := config.GetHost() + if err := p.db.GetWhere(ctx, []db.Where{{Key: "domain", Value: host}}, instance); err != nil { + return fmt.Errorf("SendConfirmEmail: error getting instance: %s", err) + } + + // assemble the email contents and send the email + confirmData := email.ConfirmData{ + Username: username, + InstanceURL: instance.URI, + InstanceName: instance.Title, + ConfirmLink: confirmationLink, + } + if err := p.emailSender.SendConfirmEmail(user.UnconfirmedEmail, confirmData); err != nil { + return fmt.Errorf("SendConfirmEmail: error sending to email address %s belonging to user %s: %s", user.UnconfirmedEmail, username, err) + } + + // email sent, now we need to update the user entry with the token we just sent them + updatingColumns := []string{"confirmation_sent_at", "confirmation_token", "last_emailed_at", "updated_at"} + user.ConfirmationSentAt = time.Now() + user.ConfirmationToken = confirmationToken + user.LastEmailedAt = time.Now() + user.UpdatedAt = time.Now() + + if err := p.db.UpdateByID(ctx, user, user.ID, updatingColumns...); err != nil { + return fmt.Errorf("SendConfirmEmail: error updating user entry after email sent: %s", err) + } + + return nil +} + +// EmailConfirm processes an email confirmation request, usually initiated as a result of clicking on a link +// in a 'confirm your email address' type email. +func (p *Processor) EmailConfirm(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) { + if token == "" { + return nil, gtserror.NewErrorNotFound(errors.New("no token provided")) + } + + user, err := p.db.GetUserByConfirmationToken(ctx, token) + if err != nil { + if err == db.ErrNoEntries { + return nil, gtserror.NewErrorNotFound(err) + } + return nil, gtserror.NewErrorInternalError(err) + } + + if user.Account == nil { + a, err := p.db.GetAccountByID(ctx, user.AccountID) + if err != nil { + return nil, gtserror.NewErrorNotFound(err) + } + user.Account = a + } + + if !user.Account.SuspendedAt.IsZero() { + return nil, gtserror.NewErrorForbidden(fmt.Errorf("ConfirmEmail: account %s is suspended", user.AccountID)) + } + + if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email { + // no pending email confirmations so just return OK + return user, nil + } + + if user.ConfirmationSentAt.Before(time.Now().Add(-oneWeek)) { + return nil, gtserror.NewErrorForbidden(errors.New("ConfirmEmail: confirmation token expired")) + } + + // mark the user's email address as confirmed + remove the unconfirmed address and the token + updatingColumns := []string{"email", "unconfirmed_email", "confirmed_at", "confirmation_token", "updated_at"} + user.Email = user.UnconfirmedEmail + user.UnconfirmedEmail = "" + user.ConfirmedAt = time.Now() + user.ConfirmationToken = "" + user.UpdatedAt = time.Now() + + if err := p.db.UpdateByID(ctx, user, user.ID, updatingColumns...); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return user, nil +} diff --git a/internal/processing/user/email_test.go b/internal/processing/user/email_test.go new file mode 100644 index 000000000..f66b7987c --- /dev/null +++ b/internal/processing/user/email_test.go @@ -0,0 +1,116 @@ +/* + GoToSocial + Copyright (C) 2021-2023 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" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type EmailConfirmTestSuite struct { + UserStandardTestSuite +} + +func (suite *EmailConfirmTestSuite) TestSendConfirmEmail() { + user := suite.testUsers["local_account_1"] + + // set a bunch of stuff on the user as though zork hasn't been confirmed (perish the thought) + user.UnconfirmedEmail = "some.email@example.org" + user.Email = "" + user.ConfirmedAt = time.Time{} + user.ConfirmationSentAt = time.Time{} + user.ConfirmationToken = "" + + err := suite.user.EmailSendConfirmation(context.Background(), user, "the_mighty_zork") + suite.NoError(err) + + // zork should have an email now + suite.Len(suite.sentEmails, 1) + email, ok := suite.sentEmails["some.email@example.org"] + suite.True(ok) + + // a token should be set on zork + token := user.ConfirmationToken + suite.NotEmpty(token) + + // email should contain the token + emailShould := fmt.Sprintf("To: some.email@example.org\r\nSubject: GoToSocial Email Confirmation\r\n\r\nHello the_mighty_zork!\r\n\r\nYou are receiving this mail because you've requested an account on http://localhost:8080.\r\n\r\nWe just need to confirm that this is your email address. To confirm your email, paste the following in your browser's address bar:\r\n\r\nhttp://localhost:8080/confirm_email?token=%s\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of http://localhost:8080\r\n\r\n", token) + suite.Equal(emailShould, email) + + // confirmationSentAt should be recent + suite.WithinDuration(time.Now(), user.ConfirmationSentAt, 1*time.Minute) +} + +func (suite *EmailConfirmTestSuite) TestConfirmEmail() { + ctx := context.Background() + + user := suite.testUsers["local_account_1"] + + // set a bunch of stuff on the user as though zork hasn't been confirmed yet, but has had an email sent 5 minutes ago + updatingColumns := []string{"unconfirmed_email", "email", "confirmed_at", "confirmation_sent_at", "confirmation_token"} + user.UnconfirmedEmail = "some.email@example.org" + user.Email = "" + user.ConfirmedAt = time.Time{} + user.ConfirmationSentAt = time.Now().Add(-5 * time.Minute) + user.ConfirmationToken = "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6" + + err := suite.db.UpdateByID(ctx, user, user.ID, updatingColumns...) + suite.NoError(err) + + // confirm with the token set above + updatedUser, errWithCode := suite.user.EmailConfirm(ctx, "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6") + suite.NoError(errWithCode) + + // email should now be confirmed and token cleared + suite.Equal("some.email@example.org", updatedUser.Email) + suite.Empty(updatedUser.UnconfirmedEmail) + suite.Empty(updatedUser.ConfirmationToken) + suite.WithinDuration(updatedUser.ConfirmedAt, time.Now(), 1*time.Minute) + suite.WithinDuration(updatedUser.UpdatedAt, time.Now(), 1*time.Minute) +} + +func (suite *EmailConfirmTestSuite) TestConfirmEmailOldToken() { + ctx := context.Background() + + user := suite.testUsers["local_account_1"] + + // set a bunch of stuff on the user as though zork hasn't been confirmed yet, but has had an email sent 8 days ago + updatingColumns := []string{"unconfirmed_email", "email", "confirmed_at", "confirmation_sent_at", "confirmation_token"} + user.UnconfirmedEmail = "some.email@example.org" + user.Email = "" + user.ConfirmedAt = time.Time{} + user.ConfirmationSentAt = time.Now().Add(-192 * time.Hour) + user.ConfirmationToken = "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6" + + err := suite.db.UpdateByID(ctx, user, user.ID, updatingColumns...) + suite.NoError(err) + + // confirm with the token set above + updatedUser, errWithCode := suite.user.EmailConfirm(ctx, "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6") + suite.Nil(updatedUser) + suite.EqualError(errWithCode, "ConfirmEmail: confirmation token expired") +} + +func TestEmailConfirmTestSuite(t *testing.T) { + suite.Run(t, &EmailConfirmTestSuite{}) +} diff --git a/internal/processing/user/emailconfirm.go b/internal/processing/user/emailconfirm.go deleted file mode 100644 index 3bc889024..000000000 --- a/internal/processing/user/emailconfirm.go +++ /dev/null @@ -1,134 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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" - "errors" - "fmt" - "time" - - "github.com/google/uuid" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/email" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/uris" -) - -var oneWeek = 168 * time.Hour - -func (p *processor) SendConfirmEmail(ctx context.Context, user *gtsmodel.User, username string) error { - if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email { - // user has already confirmed this email address, so there's nothing to do - return nil - } - - // We need a token and a link for the user to click on. - // We'll use a uuid as our token since it's basically impossible to guess. - // From the uuid package we use (which uses crypto/rand under the hood): - // Randomly generated UUIDs have 122 random bits. One's annual risk of being - // hit by a meteorite is estimated to be one chance in 17 billion, that - // means the probability is about 0.00000000006 (6 × 10−11), - // equivalent to the odds of creating a few tens of trillions of UUIDs in a - // year and having one duplicate. - confirmationToken := uuid.NewString() - confirmationLink := uris.GenerateURIForEmailConfirm(confirmationToken) - - // pull our instance entry from the database so we can greet the user nicely in the email - instance := >smodel.Instance{} - host := config.GetHost() - if err := p.db.GetWhere(ctx, []db.Where{{Key: "domain", Value: host}}, instance); err != nil { - return fmt.Errorf("SendConfirmEmail: error getting instance: %s", err) - } - - // assemble the email contents and send the email - confirmData := email.ConfirmData{ - Username: username, - InstanceURL: instance.URI, - InstanceName: instance.Title, - ConfirmLink: confirmationLink, - } - if err := p.emailSender.SendConfirmEmail(user.UnconfirmedEmail, confirmData); err != nil { - return fmt.Errorf("SendConfirmEmail: error sending to email address %s belonging to user %s: %s", user.UnconfirmedEmail, username, err) - } - - // email sent, now we need to update the user entry with the token we just sent them - updatingColumns := []string{"confirmation_sent_at", "confirmation_token", "last_emailed_at", "updated_at"} - user.ConfirmationSentAt = time.Now() - user.ConfirmationToken = confirmationToken - user.LastEmailedAt = time.Now() - user.UpdatedAt = time.Now() - - if err := p.db.UpdateByID(ctx, user, user.ID, updatingColumns...); err != nil { - return fmt.Errorf("SendConfirmEmail: error updating user entry after email sent: %s", err) - } - - return nil -} - -func (p *processor) ConfirmEmail(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) { - if token == "" { - return nil, gtserror.NewErrorNotFound(errors.New("no token provided")) - } - - user, err := p.db.GetUserByConfirmationToken(ctx, token) - if err != nil { - if err == db.ErrNoEntries { - return nil, gtserror.NewErrorNotFound(err) - } - return nil, gtserror.NewErrorInternalError(err) - } - - if user.Account == nil { - a, err := p.db.GetAccountByID(ctx, user.AccountID) - if err != nil { - return nil, gtserror.NewErrorNotFound(err) - } - user.Account = a - } - - if !user.Account.SuspendedAt.IsZero() { - return nil, gtserror.NewErrorForbidden(fmt.Errorf("ConfirmEmail: account %s is suspended", user.AccountID)) - } - - if user.UnconfirmedEmail == "" || user.UnconfirmedEmail == user.Email { - // no pending email confirmations so just return OK - return user, nil - } - - if user.ConfirmationSentAt.Before(time.Now().Add(-oneWeek)) { - return nil, gtserror.NewErrorForbidden(errors.New("ConfirmEmail: confirmation token expired")) - } - - // mark the user's email address as confirmed + remove the unconfirmed address and the token - updatingColumns := []string{"email", "unconfirmed_email", "confirmed_at", "confirmation_token", "updated_at"} - user.Email = user.UnconfirmedEmail - user.UnconfirmedEmail = "" - user.ConfirmedAt = time.Now() - user.ConfirmationToken = "" - user.UpdatedAt = time.Now() - - if err := p.db.UpdateByID(ctx, user, user.ID, updatingColumns...); err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - return user, nil -} diff --git a/internal/processing/user/emailconfirm_test.go b/internal/processing/user/emailconfirm_test.go deleted file mode 100644 index a13a130d0..000000000 --- a/internal/processing/user/emailconfirm_test.go +++ /dev/null @@ -1,116 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2023 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" - "testing" - "time" - - "github.com/stretchr/testify/suite" -) - -type EmailConfirmTestSuite struct { - UserStandardTestSuite -} - -func (suite *EmailConfirmTestSuite) TestSendConfirmEmail() { - user := suite.testUsers["local_account_1"] - - // set a bunch of stuff on the user as though zork hasn't been confirmed (perish the thought) - user.UnconfirmedEmail = "some.email@example.org" - user.Email = "" - user.ConfirmedAt = time.Time{} - user.ConfirmationSentAt = time.Time{} - user.ConfirmationToken = "" - - err := suite.user.SendConfirmEmail(context.Background(), user, "the_mighty_zork") - suite.NoError(err) - - // zork should have an email now - suite.Len(suite.sentEmails, 1) - email, ok := suite.sentEmails["some.email@example.org"] - suite.True(ok) - - // a token should be set on zork - token := user.ConfirmationToken - suite.NotEmpty(token) - - // email should contain the token - emailShould := fmt.Sprintf("To: some.email@example.org\r\nSubject: GoToSocial Email Confirmation\r\n\r\nHello the_mighty_zork!\r\n\r\nYou are receiving this mail because you've requested an account on http://localhost:8080.\r\n\r\nWe just need to confirm that this is your email address. To confirm your email, paste the following in your browser's address bar:\r\n\r\nhttp://localhost:8080/confirm_email?token=%s\r\n\r\nIf you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of http://localhost:8080\r\n\r\n", token) - suite.Equal(emailShould, email) - - // confirmationSentAt should be recent - suite.WithinDuration(time.Now(), user.ConfirmationSentAt, 1*time.Minute) -} - -func (suite *EmailConfirmTestSuite) TestConfirmEmail() { - ctx := context.Background() - - user := suite.testUsers["local_account_1"] - - // set a bunch of stuff on the user as though zork hasn't been confirmed yet, but has had an email sent 5 minutes ago - updatingColumns := []string{"unconfirmed_email", "email", "confirmed_at", "confirmation_sent_at", "confirmation_token"} - user.UnconfirmedEmail = "some.email@example.org" - user.Email = "" - user.ConfirmedAt = time.Time{} - user.ConfirmationSentAt = time.Now().Add(-5 * time.Minute) - user.ConfirmationToken = "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6" - - err := suite.db.UpdateByID(ctx, user, user.ID, updatingColumns...) - suite.NoError(err) - - // confirm with the token set above - updatedUser, errWithCode := suite.user.ConfirmEmail(ctx, "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6") - suite.NoError(errWithCode) - - // email should now be confirmed and token cleared - suite.Equal("some.email@example.org", updatedUser.Email) - suite.Empty(updatedUser.UnconfirmedEmail) - suite.Empty(updatedUser.ConfirmationToken) - suite.WithinDuration(updatedUser.ConfirmedAt, time.Now(), 1*time.Minute) - suite.WithinDuration(updatedUser.UpdatedAt, time.Now(), 1*time.Minute) -} - -func (suite *EmailConfirmTestSuite) TestConfirmEmailOldToken() { - ctx := context.Background() - - user := suite.testUsers["local_account_1"] - - // set a bunch of stuff on the user as though zork hasn't been confirmed yet, but has had an email sent 8 days ago - updatingColumns := []string{"unconfirmed_email", "email", "confirmed_at", "confirmation_sent_at", "confirmation_token"} - user.UnconfirmedEmail = "some.email@example.org" - user.Email = "" - user.ConfirmedAt = time.Time{} - user.ConfirmationSentAt = time.Now().Add(-192 * time.Hour) - user.ConfirmationToken = "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6" - - err := suite.db.UpdateByID(ctx, user, user.ID, updatingColumns...) - suite.NoError(err) - - // confirm with the token set above - updatedUser, errWithCode := suite.user.ConfirmEmail(ctx, "1d1aa44b-afa4-49c8-ac4b-eceb61715cc6") - suite.Nil(updatedUser) - suite.EqualError(errWithCode, "ConfirmEmail: confirmation token expired") -} - -func TestEmailConfirmTestSuite(t *testing.T) { - suite.Run(t, &EmailConfirmTestSuite{}) -} diff --git a/internal/processing/user/password.go b/internal/processing/user/password.go new file mode 100644 index 000000000..3475e005e --- /dev/null +++ b/internal/processing/user/password.go @@ -0,0 +1,52 @@ +/* + GoToSocial + Copyright (C) 2021-2023 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" +) + +// PasswordChange processes a password change request for the given user. +func (p *Processor) PasswordChange(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.NewErrorUnauthorized(err, "old password was incorrect") + } + + 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.UpdateUser(ctx, user, "encrypted_password"); err != nil { + return gtserror.NewErrorInternalError(err) + } + + return nil +} diff --git a/internal/processing/user/password_test.go b/internal/processing/user/password_test.go new file mode 100644 index 000000000..a02581b5b --- /dev/null +++ b/internal/processing/user/password_test.go @@ -0,0 +1,92 @@ +/* + GoToSocial + Copyright (C) 2021-2023 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.PasswordChange(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.PasswordChange(context.Background(), user, "ooooopsydoooopsy", "verygoodnewpassword") + suite.EqualError(errWithCode, "crypto/bcrypt: hashedPassword is not the hash of the given password") + suite.Equal(http.StatusUnauthorized, errWithCode.Code()) + suite.Equal("Unauthorized: old password was incorrect", errWithCode.Safe()) + + // 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 not changed + err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password")) + suite.NoError(err) +} + +func (suite *ChangePasswordTestSuite) TestChangePasswordWeakNew() { + user := suite.testUsers["local_account_1"] + + errWithCode := suite.user.PasswordChange(context.Background(), user, "password", "1234") + suite.EqualError(errWithCode, "password is only 11% strength, 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: password is only 11% strength, try including more special characters, using lowercase letters, using uppercase letters or using a longer password", errWithCode.Safe()) + + // 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 not changed + err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password")) + suite.NoError(err) +} + +func TestChangePasswordTestSuite(t *testing.T) { + suite.Run(t, &ChangePasswordTestSuite{}) +} diff --git a/internal/processing/user/user.go b/internal/processing/user/user.go index 5ce8cd803..fce628d0c 100644 --- a/internal/processing/user/user.go +++ b/internal/processing/user/user.go @@ -19,33 +19,18 @@ package user import ( - "context" - "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/email" - "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 - // SendConfirmEmail sends a 'confirm-your-email-address' type email to a user. - SendConfirmEmail(ctx context.Context, user *gtsmodel.User, username string) error - // ConfirmEmail confirms an email address using the given token. - ConfirmEmail(ctx context.Context, token string) (*gtsmodel.User, gtserror.WithCode) -} - -type processor struct { +type Processor struct { emailSender email.Sender db db.DB } // New returns a new user processor func New(db db.DB, emailSender email.Sender) Processor { - return &processor{ + return Processor{ emailSender: emailSender, db: db, } -- cgit v1.2.3