[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

@ -30,7 +30,6 @@ code.gitea.io/gitea/models/asymkey
code.gitea.io/gitea/models/auth code.gitea.io/gitea/models/auth
GetSourceByName GetSourceByName
GetWebAuthnCredentialByID
WebAuthnCredentials WebAuthnCredentials
code.gitea.io/gitea/models/db code.gitea.io/gitea/models/db

View file

@ -307,60 +307,6 @@ func updateActivation(ctx context.Context, email *EmailAddress, activate bool) e
return UpdateUserCols(ctx, user, "rands") 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 // VerifyActiveEmailCode verifies active email code when active account
func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddress { func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddress {
if user := GetVerifyUser(ctx, code); user != nil { if user := GetVerifyUser(ctx, code); user != nil {

View file

@ -43,40 +43,6 @@ func TestIsEmailUsed(t *testing.T) {
assert.False(t, isExist) 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) { func TestActivate(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) 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. // 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()) sanitizedDisplayName := emailToReplacer.Replace(u.DisplayName())
// should be an edge case but nice to have email := u.Email
if sanitizedDisplayName == u.Email { if len(overrideMail) > 0 {
return u.Email 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 { if err != nil {
return u.Email return email
} }
return address.String() return address.String()

View file

@ -625,6 +625,11 @@ func TestEmailTo(t *testing.T) {
assert.EqualValues(t, testCase.result, testUser.EmailTo()) 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) { func TestDisabledUserFeatures(t *testing.T) {

View file

@ -498,7 +498,24 @@ register_notify.text_2 = You can sign into your account using your username: %s
register_notify.text_3 = If someone else made this account for you, you will need to <a href="%s">set your password</a> first. register_notify.text_3 = If someone else made this account for you, you will need to <a href="%s">set your password</a> first.
reset_password = Recover your account reset_password = Recover your account
reset_password.text = If this was you, please click the following link to recover your account within <b>%s</b>: reset_password.text_1 = The password for your account was just changed.
password_change.subject = Your password has been changed
password_change.text_1 = The password for your account was just changed.
primary_mail_change.subject = Your primary mail has been changed
primary_mail_change.text_1 = The primary mail of your account was just changed to %[1]s. This means that this e-mail address will no longer receive e-mail notifications for your account.
totp_disabled.subject = TOTP has been disabled
totp_disabled.text_1 = Time-based one-time password (TOTP) on your account was just disabled.
totp_disabled.no_2fa = There are no other 2FA methods configured anymore, meaning it is no longer necessary to log into your account with 2FA.
removed_security_key.subject = A security key has been removed
removed_security_key.text_1 = Security key "%[1]s" has just been removed from your account.
removed_security_key.no_2fa = There are no other 2FA methods configured anymore, meaning it is no longer necessary to log into your account with 2FA.
account_security_caution.text_1 = If this was you, then you can safely ignore this mail.
account_security_caution.text_2 = If this wasn't you, your account is compromised. Please contact the admins of this site.
register_success = Registration successful register_success = Registration successful

1
release-notes/4635.md Normal file
View file

@ -0,0 +1 @@
Email notifications are now sent when account security changes are made: password changed, primary email changed (email sent to old primary mail), TOTP disabled or a security key removed.

View file

@ -104,7 +104,15 @@ func EmailPost(ctx *context.Context) {
// Make emailaddress primary. // Make emailaddress primary.
if ctx.FormString("_method") == "PRIMARY" { if ctx.FormString("_method") == "PRIMARY" {
if err := user_model.MakeEmailPrimary(ctx, &user_model.EmailAddress{ID: ctx.FormInt64("id")}); err != nil { id := ctx.FormInt64("id")
email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id)
if err != nil {
log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err)
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
return
}
if err := user.MakeEmailAddressPrimary(ctx, ctx.Doer, email, true); err != nil {
ctx.ServerError("MakeEmailPrimary", err) ctx.ServerError("MakeEmailPrimary", err)
return return
} }

View file

@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/mailer"
"github.com/pquerna/otp" "github.com/pquerna/otp"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
@ -78,6 +79,11 @@ func DisableTwoFactor(ctx *context.Context) {
return return
} }
if err := mailer.SendDisabledTOTP(ctx, ctx.Doer); err != nil {
ctx.ServerError("SendDisabledTOTP", err)
return
}
ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) ctx.Flash.Success(ctx.Tr("settings.twofa_disabled"))
ctx.Redirect(setting.AppSubURL + "/user/settings/security") ctx.Redirect(setting.AppSubURL + "/user/settings/security")
} }

View file

@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/mailer"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
@ -112,9 +113,25 @@ func WebauthnRegisterPost(ctx *context.Context) {
// WebauthnDelete deletes an security key by id // WebauthnDelete deletes an security key by id
func WebauthnDelete(ctx *context.Context) { func WebauthnDelete(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.WebauthnDeleteForm) form := web.GetForm(ctx).(*forms.WebauthnDeleteForm)
cred, err := auth.GetWebAuthnCredentialByID(ctx, form.ID)
if err != nil || cred.UserID != ctx.Doer.ID {
if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) {
log.Error("GetWebAuthnCredentialByID: %v", err)
}
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
return
}
if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil { if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil {
ctx.ServerError("GetWebAuthnCredentialByID", err) ctx.ServerError("GetWebAuthnCredentialByID", err)
return return
} }
if err := mailer.SendRemovedSecurityKey(ctx, ctx.Doer, cred.Name); err != nil {
ctx.ServerError("SendRemovedSecurityKey", err)
return
}
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
} }

View file

@ -17,6 +17,7 @@ import (
"time" "time"
activities_model "code.gitea.io/gitea/models/activities" activities_model "code.gitea.io/gitea/models/activities"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
@ -39,6 +40,10 @@ const (
mailAuthActivateEmail base.TplName = "auth/activate_email" mailAuthActivateEmail base.TplName = "auth/activate_email"
mailAuthResetPassword base.TplName = "auth/reset_passwd" mailAuthResetPassword base.TplName = "auth/reset_passwd"
mailAuthRegisterNotify base.TplName = "auth/register_notify" mailAuthRegisterNotify base.TplName = "auth/register_notify"
mailAuthPasswordChange base.TplName = "auth/password_change"
mailAuthPrimaryMailChange base.TplName = "auth/primary_mail_change"
mailAuth2faDisabled base.TplName = "auth/2fa_disabled"
mailAuthRemovedSecurityKey base.TplName = "auth/removed_security_key"
mailNotifyCollaborator base.TplName = "notify/collaborator" mailNotifyCollaborator base.TplName = "notify/collaborator"
@ -561,3 +566,133 @@ func fromDisplayName(u *user_model.User) string {
} }
return u.GetCompleteName() return u.GetCompleteName()
} }
// SendPasswordChange informs the user on their primary email address that
// their password was changed.
func SendPasswordChange(u *user_model.User) error {
if setting.MailService == nil {
return nil
}
locale := translation.NewLocale(u.Language)
data := map[string]any{
"locale": locale,
"DisplayName": u.DisplayName(),
"Username": u.Name,
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthPasswordChange), data); err != nil {
return err
}
msg := NewMessage(u.EmailTo(), locale.TrString("mail.password_change.subject"), content.String())
msg.Info = fmt.Sprintf("UID: %d, password change notification", u.ID)
SendAsync(msg)
return nil
}
// SendPrimaryMailChange informs the user on their old primary email address
// that it's no longer used as primary mail and will no longer receive
// notification on that email address.
func SendPrimaryMailChange(u *user_model.User, oldPrimaryEmail string) error {
if setting.MailService == nil {
return nil
}
locale := translation.NewLocale(u.Language)
data := map[string]any{
"locale": locale,
"NewPrimaryMail": u.Email,
"DisplayName": u.DisplayName(),
"Username": u.Name,
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthPrimaryMailChange), data); err != nil {
return err
}
msg := NewMessage(u.EmailTo(oldPrimaryEmail), locale.TrString("mail.primary_mail_change.subject"), content.String())
msg.Info = fmt.Sprintf("UID: %d, primary email change notification", u.ID)
SendAsync(msg)
return nil
}
// SendDisabledTOTP informs the user that their totp has been disabled.
func SendDisabledTOTP(ctx context.Context, u *user_model.User) error {
if setting.MailService == nil {
return nil
}
locale := translation.NewLocale(u.Language)
hasWebAuthn, err := auth_model.HasWebAuthnRegistrationsByUID(ctx, u.ID)
if err != nil {
return err
}
data := map[string]any{
"locale": locale,
"HasWebAuthn": hasWebAuthn,
"DisplayName": u.DisplayName(),
"Username": u.Name,
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuth2faDisabled), data); err != nil {
return err
}
msg := NewMessage(u.EmailTo(), locale.TrString("mail.totp_disabled.subject"), content.String())
msg.Info = fmt.Sprintf("UID: %d, 2fa disabled notification", u.ID)
SendAsync(msg)
return nil
}
// SendRemovedWebAuthn informs the user that one of their security keys has been removed.
func SendRemovedSecurityKey(ctx context.Context, u *user_model.User, securityKeyName string) error {
if setting.MailService == nil {
return nil
}
locale := translation.NewLocale(u.Language)
hasWebAuthn, err := auth_model.HasWebAuthnRegistrationsByUID(ctx, u.ID)
if err != nil {
return err
}
hasTOTP, err := auth_model.HasTwoFactorByUID(ctx, u.ID)
if err != nil {
return err
}
data := map[string]any{
"locale": locale,
"HasWebAuthn": hasWebAuthn,
"HasTOTP": hasTOTP,
"SecurityKeyName": securityKeyName,
"DisplayName": u.DisplayName(),
"Username": u.Name,
"Language": locale.Language(),
}
var content bytes.Buffer
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRemovedSecurityKey), data); err != nil {
return err
}
msg := NewMessage(u.EmailTo(), locale.TrString("mail.removed_security_key.subject"), content.String())
msg.Info = fmt.Sprintf("UID: %d, security key removed notification", u.ID)
SendAsync(msg)
return nil
}

View file

@ -55,14 +55,14 @@ func TestAdminNotificationMail_test(t *testing.T) {
defer test.MockVariableValue(&setting.Admin.SendNotificationEmailOnNewUser, true)() defer test.MockVariableValue(&setting.Admin.SendNotificationEmailOnNewUser, true)()
called := false called := false
defer mockMailSettings(func(msgs ...*Message) { defer MockMailSettings(func(msgs ...*Message) {
assert.Equal(t, len(msgs), 1, "Test provides only one admin user, so only one email must be sent") assert.Equal(t, len(msgs), 1, "Test provides only one admin user, so only one email must be sent")
assert.Equal(t, msgs[0].To, users[0].Email, "checks if the recipient is the admin of the instance") assert.Equal(t, msgs[0].To, users[0].Email, "checks if the recipient is the admin of the instance")
manageUserURL := setting.AppURL + "admin/users/" + strconv.FormatInt(users[1].ID, 10) manageUserURL := setting.AppURL + "admin/users/" + strconv.FormatInt(users[1].ID, 10)
assert.Contains(t, msgs[0].Body, manageUserURL) assert.Contains(t, msgs[0].Body, manageUserURL)
assert.Contains(t, msgs[0].Body, users[1].HTMLURL()) assert.Contains(t, msgs[0].Body, users[1].HTMLURL())
assert.Contains(t, msgs[0].Body, users[1].Name, "user name of the newly created user") assert.Contains(t, msgs[0].Body, users[1].Name, "user name of the newly created user")
assertTranslatedLocale(t, msgs[0].Body, "mail.admin", "admin.users") AssertTranslatedLocale(t, msgs[0].Body, "mail.admin", "admin.users")
called = true called = true
})() })()
MailNewUser(ctx, users[1]) MailNewUser(ctx, users[1])
@ -71,7 +71,7 @@ func TestAdminNotificationMail_test(t *testing.T) {
t.Run("SendNotificationEmailOnNewUser_false", func(t *testing.T) { t.Run("SendNotificationEmailOnNewUser_false", func(t *testing.T) {
defer test.MockVariableValue(&setting.Admin.SendNotificationEmailOnNewUser, false)() defer test.MockVariableValue(&setting.Admin.SendNotificationEmailOnNewUser, false)()
defer mockMailSettings(func(msgs ...*Message) { defer MockMailSettings(func(msgs ...*Message) {
assert.Equal(t, 1, 0, "this shouldn't execute. MailNewUser must exit early since SEND_NOTIFICATION_EMAIL_ON_NEW_USER is disabled") assert.Equal(t, 1, 0, "this shouldn't execute. MailNewUser must exit early since SEND_NOTIFICATION_EMAIL_ON_NEW_USER is disabled")
})() })()
MailNewUser(ctx, users[1]) MailNewUser(ctx, users[1])

View file

@ -0,0 +1,60 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mailer_test
import (
"testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/services/mailer"
user_service "code.gitea.io/gitea/services/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPasswordChangeMail(t *testing.T) {
defer require.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
called := false
defer mailer.MockMailSettings(func(msgs ...*mailer.Message) {
assert.Len(t, msgs, 1)
assert.Equal(t, user.EmailTo(), msgs[0].To)
assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.password_change.subject"), msgs[0].Subject)
mailer.AssertTranslatedLocale(t, msgs[0].Body, "mail.password_change.text_1", "mail.password_change.text_2", "mail.password_change.text_3")
called = true
})()
require.NoError(t, user_service.UpdateAuth(db.DefaultContext, user, &user_service.UpdateAuthOptions{Password: optional.Some("NewPasswordYolo!")}))
assert.True(t, called)
}
func TestPrimaryMailChange(t *testing.T) {
defer require.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
firstEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 3, UID: user.ID, IsPrimary: true})
secondEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}, "is_primary = false")
called := false
defer mailer.MockMailSettings(func(msgs ...*mailer.Message) {
assert.False(t, called)
assert.Len(t, msgs, 1)
assert.Equal(t, user.EmailTo(firstEmail.Email), msgs[0].To)
assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.primary_mail_change.subject"), msgs[0].Subject)
assert.Contains(t, msgs[0].Body, secondEmail.Email)
mailer.AssertTranslatedLocale(t, msgs[0].Body, "mail.primary_mail_change.text_1", "mail.primary_mail_change.text_2", "mail.primary_mail_change.text_3")
called = true
})()
require.NoError(t, user_service.MakeEmailAddressPrimary(db.DefaultContext, user, secondEmail, true))
assert.True(t, called)
require.NoError(t, user_service.MakeEmailAddressPrimary(db.DefaultContext, user, firstEmail, false))
}

View file

@ -62,7 +62,7 @@ func prepareMailerTest(t *testing.T) (doer *user_model.User, repo *repo_model.Re
} }
func TestComposeIssueCommentMessage(t *testing.T) { func TestComposeIssueCommentMessage(t *testing.T) {
defer mockMailSettings(nil)() defer MockMailSettings(nil)()
doer, _, issue, comment := prepareMailerTest(t) doer, _, issue, comment := prepareMailerTest(t)
markup.Init(&markup.ProcessorHelper{ markup.Init(&markup.ProcessorHelper{
@ -117,7 +117,7 @@ func TestComposeIssueCommentMessage(t *testing.T) {
} }
func TestComposeIssueMessage(t *testing.T) { func TestComposeIssueMessage(t *testing.T) {
defer mockMailSettings(nil)() defer MockMailSettings(nil)()
doer, _, issue, _ := prepareMailerTest(t) doer, _, issue, _ := prepareMailerTest(t)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}} recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
@ -146,7 +146,7 @@ func TestComposeIssueMessage(t *testing.T) {
} }
func TestMailerIssueTemplate(t *testing.T) { func TestMailerIssueTemplate(t *testing.T) {
defer mockMailSettings(nil)() defer MockMailSettings(nil)()
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
@ -160,7 +160,7 @@ func TestMailerIssueTemplate(t *testing.T) {
for _, s := range expected { for _, s := range expected {
assert.Contains(t, wholemsg, s) assert.Contains(t, wholemsg, s)
} }
assertTranslatedLocale(t, wholemsg, "mail.issue") AssertTranslatedLocale(t, wholemsg, "mail.issue")
} }
testCompose := func(t *testing.T, ctx *mailCommentContext) *Message { testCompose := func(t *testing.T, ctx *mailCommentContext) *Message {
@ -241,7 +241,7 @@ func TestMailerIssueTemplate(t *testing.T) {
} }
func TestTemplateSelection(t *testing.T) { func TestTemplateSelection(t *testing.T) {
defer mockMailSettings(nil)() defer MockMailSettings(nil)()
doer, repo, issue, comment := prepareMailerTest(t) doer, repo, issue, comment := prepareMailerTest(t)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}} recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
@ -296,7 +296,7 @@ func TestTemplateSelection(t *testing.T) {
} }
func TestTemplateServices(t *testing.T) { func TestTemplateServices(t *testing.T) {
defer mockMailSettings(nil)() defer MockMailSettings(nil)()
doer, _, issue, comment := prepareMailerTest(t) doer, _, issue, comment := prepareMailerTest(t)
assert.NoError(t, issue.LoadRepo(db.DefaultContext)) assert.NoError(t, issue.LoadRepo(db.DefaultContext))
@ -349,7 +349,7 @@ func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recip
} }
func TestGenerateAdditionalHeaders(t *testing.T) { func TestGenerateAdditionalHeaders(t *testing.T) {
defer mockMailSettings(nil)() defer MockMailSettings(nil)()
doer, _, issue, _ := prepareMailerTest(t) doer, _, issue, _ := prepareMailerTest(t)
ctx := &mailCommentContext{Context: context.TODO() /* TODO: use a correct context */, Issue: issue, Doer: doer} ctx := &mailCommentContext{Context: context.TODO() /* TODO: use a correct context */, Issue: issue, Doer: doer}
@ -382,7 +382,7 @@ func TestGenerateAdditionalHeaders(t *testing.T) {
} }
func Test_createReference(t *testing.T) { func Test_createReference(t *testing.T) {
defer mockMailSettings(nil)() defer MockMailSettings(nil)()
_, _, issue, comment := prepareMailerTest(t) _, _, issue, comment := prepareMailerTest(t)
_, _, pullIssue, _ := prepareMailerTest(t) _, _, pullIssue, _ := prepareMailerTest(t)
pullIssue.IsPull = true pullIssue.IsPull = true

View file

@ -22,14 +22,14 @@ func TestMain(m *testing.M) {
unittest.MainTest(m) unittest.MainTest(m)
} }
func assertTranslatedLocale(t *testing.T, message string, prefixes ...string) { func AssertTranslatedLocale(t *testing.T, message string, prefixes ...string) {
t.Helper() t.Helper()
for _, prefix := range prefixes { for _, prefix := range prefixes {
assert.NotContains(t, message, prefix, "there is an untranslated locale prefix") assert.NotContains(t, message, prefix, "there is an untranslated locale prefix")
} }
} }
func mockMailSettings(send func(msgs ...*Message)) func() { func MockMailSettings(send func(msgs ...*Message)) func() {
translation.InitLocales(context.Background()) translation.InitLocales(context.Background())
subjectTemplates, bodyTemplates = templates.Mailer(context.Background()) subjectTemplates, bodyTemplates = templates.Mailer(context.Background())
mailService := setting.Mailer{ mailService := setting.Mailer{

View file

@ -12,6 +12,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/mailer"
) )
// AdminAddOrSetPrimaryEmailAddress is used by admins to add or set a user's primary email address // AdminAddOrSetPrimaryEmailAddress is used by admins to add or set a user's primary email address
@ -163,7 +164,7 @@ func ReplaceInactivePrimaryEmail(ctx context.Context, oldEmail string, email *us
return err return err
} }
err = user_model.MakeEmailPrimaryWithUser(ctx, user, email) err = MakeEmailAddressPrimary(ctx, user, email, false)
if err != nil { if err != nil {
return err return err
} }
@ -190,3 +191,42 @@ func DeleteEmailAddresses(ctx context.Context, u *user_model.User, emails []stri
return nil return nil
} }
func MakeEmailAddressPrimary(ctx context.Context, u *user_model.User, newPrimaryEmail *user_model.EmailAddress, notify bool) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
sess := db.GetEngine(ctx)
oldPrimaryEmail := u.Email
// 1. Update user table
u.Email = newPrimaryEmail.Email
if _, err = sess.ID(u.ID).Cols("email").Update(u); err != nil {
return err
}
// 2. Update old primary email
if _, err = sess.Where("uid=? AND is_primary=?", u.ID, true).Cols("is_primary").Update(&user_model.EmailAddress{
IsPrimary: false,
}); err != nil {
return err
}
// 3. update new primary email
newPrimaryEmail.IsPrimary = true
if _, err = sess.ID(newPrimaryEmail.ID).Cols("is_primary").Update(newPrimaryEmail); err != nil {
return err
}
if err := committer.Commit(); err != nil {
return err
}
if notify {
return mailer.SendPrimaryMailChange(u, oldPrimaryEmail)
}
return nil
}

View file

@ -14,6 +14,7 @@ import (
"github.com/gobwas/glob" "github.com/gobwas/glob"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestAdminAddOrSetPrimaryEmailAddress(t *testing.T) { func TestAdminAddOrSetPrimaryEmailAddress(t *testing.T) {
@ -163,3 +164,15 @@ func TestDeleteEmailAddresses(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.True(t, user_model.IsErrPrimaryEmailCannotDelete(err)) assert.True(t, user_model.IsErrPrimaryEmailCannotDelete(err))
} }
func TestMakeEmailAddressPrimary(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
newPrimaryEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}, "is_primary = false")
require.NoError(t, MakeEmailAddressPrimary(db.DefaultContext, user, newPrimaryEmail, false))
unittest.AssertExistsIf(t, true, &user_model.User{ID: 2, Email: newPrimaryEmail.Email})
unittest.AssertExistsIf(t, true, &user_model.EmailAddress{ID: 3, UID: user.ID}, "is_primary = false")
unittest.AssertExistsIf(t, true, &user_model.EmailAddress{ID: 35, UID: user.ID, IsPrimary: true})
}

View file

@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/mailer"
) )
type UpdateOptions struct { type UpdateOptions struct {
@ -220,5 +221,13 @@ func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions
u.ProhibitLogin = opts.ProhibitLogin.Value() u.ProhibitLogin = opts.ProhibitLogin.Value()
} }
return user_model.UpdateUserCols(ctx, u, "login_type", "login_source", "login_name", "passwd", "passwd_hash_algo", "salt", "must_change_password", "prohibit_login") if err := user_model.UpdateUserCols(ctx, u, "login_type", "login_source", "login_name", "passwd", "passwd_hash_algo", "salt", "must_change_password", "prohibit_login"); err != nil {
return err
}
if opts.Password.Has() {
return mailer.SendPasswordChange(u)
}
return nil
} }

View file

@ -0,0 +1,15 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
</head>
<body>
<p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p><br>
<p>{{.locale.Tr "mail.totp_disabled.text_1"}}</p><br>
{{if not .HasWebAuthn}}<p>{{.locale.Tr "mail.totp_disabled.no_2fa"}}</p><br>{{end}}
<p>{{.locale.Tr "mail.account_security_caution.text_1"}}</p><br>
<p>{{.locale.Tr "mail.account_security_caution.text_2"}}</p><br>
{{template "common/footer_simple" .}}
</body>
</html>

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
</head>
<body>
<p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p><br>
<p>{{.locale.Tr "mail.password_change.text_1"}}</p><br>
<p>{{.locale.Tr "mail.account_security_caution.text_1"}}</p><br>
<p>{{.locale.Tr "mail.account_security_caution.text_2"}}</p><br>
{{template "common/footer_simple" .}}
</body>
</html>

View file

@ -0,0 +1,14 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
</head>
<body>
<p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p><br>
<p>{{.locale.Tr "mail.primary_mail_change.text_1" .NewPrimaryMail}}</p><br>
<p>{{.locale.Tr "mail.account_security_caution.text_1"}}</p><br>
<p>{{.locale.Tr "mail.account_security_caution.text_2"}}</p><br>
{{template "common/footer_simple" .}}
</body>
</html>

View file

@ -0,0 +1,15 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
</head>
<body>
<p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p><br>
<p>{{.locale.Tr "mail.removed_security_key.text_1" .SecurityKeyName}}</p><br>
{{if and (not .HasWebAuthn) (not .HasTOTP)}}<p>{{.locale.Tr "mail.removed_security_key.no_2fa"}}</p><br>{{end}}
<p>{{.locale.Tr "mail.account_security_caution.text_1"}}</p><br>
<p>{{.locale.Tr "mail.account_security_caution.text_2"}}</p><br>
{{template "common/footer_simple" .}}
</body>
</html>

View file

@ -0,0 +1 @@
<p><a target="_blank" rel="noopener noreferrer" href="{{$.AppUrl}}">{{AppName}}</a></p>

View file

@ -7,6 +7,7 @@ package integration
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"testing" "testing"
@ -20,6 +21,7 @@ import (
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/services/mailer"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -608,3 +610,140 @@ func TestUserPronouns(t *testing.T) {
assert.EqualValues(t, userName, "user2") assert.EqualValues(t, userName, "user2")
}) })
} }
func TestUserTOTPMail(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user.Name)
t.Run("No security keys", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
called := false
defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
assert.Len(t, msgs, 1)
assert.Equal(t, user.EmailTo(), msgs[0].To)
assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_disabled.subject"), msgs[0].Subject)
assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_disabled.no_2fa"))
called = true
})()
unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID})
req := NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/disable", map[string]string{
"_csrf": GetCSRF(t, session, "/user/settings/security"),
})
session.MakeRequest(t, req, http.StatusSeeOther)
assert.True(t, called)
unittest.AssertExistsIf(t, false, &auth_model.TwoFactor{UID: user.ID})
})
t.Run("with security keys", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
called := false
defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
assert.Len(t, msgs, 1)
assert.Equal(t, user.EmailTo(), msgs[0].To)
assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_disabled.subject"), msgs[0].Subject)
assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_disabled.no_2fa"))
called = true
})()
unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID})
unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID})
req := NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/disable", map[string]string{
"_csrf": GetCSRF(t, session, "/user/settings/security"),
})
session.MakeRequest(t, req, http.StatusSeeOther)
assert.True(t, called)
unittest.AssertExistsIf(t, false, &auth_model.TwoFactor{UID: user.ID})
})
}
func TestUserSecurityKeyMail(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user.Name)
t.Run("Normal", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
called := false
defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
assert.Len(t, msgs, 1)
assert.Equal(t, user.EmailTo(), msgs[0].To)
assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject)
assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa"))
assert.Contains(t, msgs[0].Body, "Little Bobby Tables&#39;s primary key")
called = true
})()
unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"})
id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID
req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{
"_csrf": GetCSRF(t, session, "/user/settings/security"),
"id": strconv.FormatInt(id, 10),
})
session.MakeRequest(t, req, http.StatusOK)
assert.True(t, called)
unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID})
})
t.Run("With TOTP", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
called := false
defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
assert.Len(t, msgs, 1)
assert.Equal(t, user.EmailTo(), msgs[0].To)
assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject)
assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa"))
assert.Contains(t, msgs[0].Body, "Little Bobby Tables&#39;s primary key")
called = true
})()
unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"})
id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID
unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID})
req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{
"_csrf": GetCSRF(t, session, "/user/settings/security"),
"id": strconv.FormatInt(id, 10),
})
session.MakeRequest(t, req, http.StatusOK)
assert.True(t, called)
unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID})
})
t.Run("Two security keys", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
called := false
defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
assert.Len(t, msgs, 1)
assert.Equal(t, user.EmailTo(), msgs[0].To)
assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject)
assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa"))
assert.Contains(t, msgs[0].Body, "Little Bobby Tables&#39;s primary key")
called = true
})()
unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"})
id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID
unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's evil key"})
req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{
"_csrf": GetCSRF(t, session, "/user/settings/security"),
"id": strconv.FormatInt(id, 10),
})
session.MakeRequest(t, req, http.StatusOK)
assert.True(t, called)
unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"})
unittest.AssertExistsIf(t, true, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's evil key"})
})
}