mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-11-30 23:33:31 -06:00
smtp + email confirmation (#285)
* add smtp configuration * add email confirm + reset templates * add email sender to testrig * flesh out the email sender interface * go fmt * golint * update from field with more clarity * tidy up the email formatting * fix tests * add email sender to processor * tidy client api processing a bit * further tidying in fromClientAPI * pin new account to user * send msg to processor on new account creation * generate confirm email uri * remove emailer from account processor again * add processCreateAccountFromClientAPI * move emailer accountprocessor => userprocessor * add email sender to user processor * SendConfirmEmail function * add noop email sender * use noop email sender in tests * only assemble message if callback is not nil * use noop email sender if no smtp host is defined * minify email html before sending * fix wrong email address * email confirm test * fmt * serve web hndler * add email confirm handler * init test log properly on testrig * log emails that *would* have been sent * go fmt ./... * unexport confirm email handler * updatedAt * test confirm email function * don't allow tokens older than 7 days * change error message a bit * add basic smtp docs * add a few more snippets * typo * add email sender to outbox tests * don't use dutch wikipedia link * don't minify email html
This commit is contained in:
parent
de1f90ee46
commit
2aaec82732
56 changed files with 1543 additions and 398 deletions
132
internal/processing/user/emailconfirm.go
Normal file
132
internal/processing/user/emailconfirm.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
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 user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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/util"
|
||||
)
|
||||
|
||||
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 := util.GenerateURIForEmailConfirm(p.config.Protocol, p.config.Host, confirmationToken)
|
||||
|
||||
// pull our instance entry from the database so we can greet the user nicely in the email
|
||||
instance := >smodel.Instance{}
|
||||
if err := p.db.GetWhere(ctx, []db.Where{{Key: "domain", Value: p.config.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
|
||||
user.ConfirmationSentAt = time.Now()
|
||||
user.ConfirmationToken = confirmationToken
|
||||
user.LastEmailedAt = time.Now()
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
if err := p.db.UpdateByPrimaryKey(ctx, user); 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 := >smodel.User{}
|
||||
if err := p.db.GetWhere(ctx, []db.Where{{Key: "confirmation_token", Value: token}}, user); 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
|
||||
user.Email = user.UnconfirmedEmail
|
||||
user.UnconfirmedEmail = ""
|
||||
user.ConfirmedAt = time.Now()
|
||||
user.ConfirmationToken = ""
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
if err := p.db.UpdateByPrimaryKey(ctx, user); err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
114
internal/processing/user/emailconfirm_test.go
Normal file
114
internal/processing/user/emailconfirm_test.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
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 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("Subject: GoToSocial Email Confirmation\r\nFrom: GoToSocial <test@example.org>\r\nTo: some.email@example.org\r\nMIME-version: 1.0;\nContent-Type: text/html;\r\n<!DOCTYPE html>\n<html>\n </head>\n <body>\n <div>\n <h1>\n Hello the_mighty_zork!\n </h1>\n </div>\n <div>\n <p>\n You are receiving this mail because you've requested an account on <a href=\"http://localhost:8080\">localhost:8080</a>.\n </p>\n <p>\n We just need to confirm that this is your email address. To confirm your email, <a href=\"http://localhost:8080/confirm_email?token=%s\">click here</a> or paste the following in your browser's address bar:\n </p>\n <p>\n <code>\n http://localhost:8080/confirm_email?token=%s\n </code>\n </p>\n </div>\n <div>\n <p>\n If you believe you've been sent this email in error, feel free to ignore it, or contact the administrator of <a href=\"http://localhost:8080\">localhost:8080</a>.\n </p>\n </div>\n </body>\n</html>\r\n", token, 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
|
||||
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.UpdateByPrimaryKey(ctx, user)
|
||||
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
|
||||
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.UpdateByPrimaryKey(ctx, user)
|
||||
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{})
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import (
|
|||
|
||||
"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"
|
||||
)
|
||||
|
|
@ -32,17 +33,23 @@ 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 {
|
||||
config *config.Config
|
||||
db db.DB
|
||||
config *config.Config
|
||||
emailSender email.Sender
|
||||
db db.DB
|
||||
}
|
||||
|
||||
// New returns a new user processor
|
||||
func New(db db.DB, config *config.Config) Processor {
|
||||
func New(db db.DB, emailSender email.Sender, config *config.Config) Processor {
|
||||
return &processor{
|
||||
config: config,
|
||||
db: db,
|
||||
config: config,
|
||||
emailSender: emailSender,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/user"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
|
|
@ -29,11 +30,14 @@ import (
|
|||
|
||||
type UserStandardTestSuite struct {
|
||||
suite.Suite
|
||||
config *config.Config
|
||||
db db.DB
|
||||
config *config.Config
|
||||
emailSender email.Sender
|
||||
db db.DB
|
||||
|
||||
testUsers map[string]*gtsmodel.User
|
||||
|
||||
sentEmails map[string]string
|
||||
|
||||
user user.Processor
|
||||
}
|
||||
|
||||
|
|
@ -41,8 +45,11 @@ func (suite *UserStandardTestSuite) SetupTest() {
|
|||
testrig.InitTestLog()
|
||||
suite.config = testrig.NewTestConfig()
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.sentEmails = make(map[string]string)
|
||||
suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails)
|
||||
suite.testUsers = testrig.NewTestUsers()
|
||||
suite.user = user.New(suite.db, suite.config)
|
||||
|
||||
suite.user = user.New(suite.db, suite.emailSender, suite.config)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue