[SECURITY] Notify users about account security changes

- Currently if the password, primary mail, TOTP or security keys are
changed, no notification is made of that and makes compromising an
account a bit easier as it's essentially undetectable until the original
person tries to log in. Although other changes should be made as
well (re-authing before allowing a password change), this should go a
long way of improving the account security in Forgejo.
- Adds a mail notification for password and primary mail changes. For
the primary mail change, a mail notification is sent to the old primary
mail.
- Add a mail notification when TOTP or a security keys is removed, if no
other 2FA method is configured the mail will also contain that 2FA is
no longer needed to log into their account.
- `MakeEmailAddressPrimary` is refactored to the user service package,
as it now involves calling the mailer service.
- Unit tests added.
- Integration tests added.
This commit is contained in:
Gusted 2024-07-23 00:17:06 +02:00
parent ded237ee77
commit 4383da91bd
No known key found for this signature in database
GPG key ID: FD821B732837125F
24 changed files with 543 additions and 116 deletions

View file

@ -307,60 +307,6 @@ func updateActivation(ctx context.Context, email *EmailAddress, activate bool) e
return UpdateUserCols(ctx, user, "rands")
}
func MakeEmailPrimaryWithUser(ctx context.Context, user *User, email *EmailAddress) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
sess := db.GetEngine(ctx)
// 1. Update user table
user.Email = email.Email
if _, err = sess.ID(user.ID).Cols("email").Update(user); err != nil {
return err
}
// 2. Update old primary email
if _, err = sess.Where("uid=? AND is_primary=?", email.UID, true).Cols("is_primary").Update(&EmailAddress{
IsPrimary: false,
}); err != nil {
return err
}
// 3. update new primary email
email.IsPrimary = true
if _, err = sess.ID(email.ID).Cols("is_primary").Update(email); err != nil {
return err
}
return committer.Commit()
}
// MakeEmailPrimary sets primary email address of given user.
func MakeEmailPrimary(ctx context.Context, email *EmailAddress) error {
has, err := db.GetEngine(ctx).Get(email)
if err != nil {
return err
} else if !has {
return ErrEmailAddressNotExist{Email: email.Email}
}
if !email.IsActivated {
return ErrEmailNotActivated
}
user := &User{}
has, err = db.GetEngine(ctx).ID(email.UID).Get(user)
if err != nil {
return err
} else if !has {
return ErrUserNotExist{UID: email.UID}
}
return MakeEmailPrimaryWithUser(ctx, user, email)
}
// VerifyActiveEmailCode verifies active email code when active account
func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddress {
if user := GetVerifyUser(ctx, code); user != nil {

View file

@ -43,40 +43,6 @@ func TestIsEmailUsed(t *testing.T) {
assert.False(t, isExist)
}
func TestMakeEmailPrimary(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
email := &user_model.EmailAddress{
Email: "user567890@example.com",
}
err := user_model.MakeEmailPrimary(db.DefaultContext, email)
assert.Error(t, err)
assert.EqualError(t, err, user_model.ErrEmailAddressNotExist{Email: email.Email}.Error())
email = &user_model.EmailAddress{
Email: "user11@example.com",
}
err = user_model.MakeEmailPrimary(db.DefaultContext, email)
assert.Error(t, err)
assert.EqualError(t, err, user_model.ErrEmailNotActivated.Error())
email = &user_model.EmailAddress{
Email: "user9999999@example.com",
}
err = user_model.MakeEmailPrimary(db.DefaultContext, email)
assert.Error(t, err)
assert.True(t, user_model.IsErrUserNotExist(err))
email = &user_model.EmailAddress{
Email: "user101@example.com",
}
err = user_model.MakeEmailPrimary(db.DefaultContext, email)
assert.NoError(t, err)
user, _ := user_model.GetUserByID(db.DefaultContext, int64(10))
assert.Equal(t, "user101@example.com", user.Email)
}
func TestActivate(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

View file

@ -451,17 +451,22 @@ var emailToReplacer = strings.NewReplacer(
)
// EmailTo returns a string suitable to be put into a e-mail `To:` header.
func (u *User) EmailTo() string {
func (u *User) EmailTo(overrideMail ...string) string {
sanitizedDisplayName := emailToReplacer.Replace(u.DisplayName())
// should be an edge case but nice to have
if sanitizedDisplayName == u.Email {
return u.Email
email := u.Email
if len(overrideMail) > 0 {
email = overrideMail[0]
}
address, err := mail.ParseAddress(fmt.Sprintf("%s <%s>", sanitizedDisplayName, u.Email))
// should be an edge case but nice to have
if sanitizedDisplayName == email {
return email
}
address, err := mail.ParseAddress(fmt.Sprintf("%s <%s>", sanitizedDisplayName, email))
if err != nil {
return u.Email
return email
}
return address.String()

View file

@ -625,6 +625,11 @@ func TestEmailTo(t *testing.T) {
assert.EqualValues(t, testCase.result, testUser.EmailTo())
})
}
t.Run("Override user's email", func(t *testing.T) {
testUser := &user_model.User{FullName: "Christine Jorgensen", Email: "christine@test.com"}
assert.EqualValues(t, `"Christine Jorgensen" <christine@example.org>`, testUser.EmailTo("christine@example.org"))
})
}
func TestDisabledUserFeatures(t *testing.T) {