[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
GetSourceByName
GetWebAuthnCredentialByID
WebAuthnCredentials
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")
}
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) {

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.
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

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.
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)
return
}

View file

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

View file

@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/mailer"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
@ -112,9 +113,25 @@ func WebauthnRegisterPost(ctx *context.Context) {
// WebauthnDelete deletes an security key by id
func WebauthnDelete(ctx *context.Context) {
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 {
ctx.ServerError("GetWebAuthnCredentialByID", err)
return
}
if err := mailer.SendRemovedSecurityKey(ctx, ctx.Doer, cred.Name); err != nil {
ctx.ServerError("SendRemovedSecurityKey", err)
return
}
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
}

View file

@ -17,6 +17,7 @@ import (
"time"
activities_model "code.gitea.io/gitea/models/activities"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
@ -35,10 +36,14 @@ import (
)
const (
mailAuthActivate base.TplName = "auth/activate"
mailAuthActivateEmail base.TplName = "auth/activate_email"
mailAuthResetPassword base.TplName = "auth/reset_passwd"
mailAuthRegisterNotify base.TplName = "auth/register_notify"
mailAuthActivate base.TplName = "auth/activate"
mailAuthActivateEmail base.TplName = "auth/activate_email"
mailAuthResetPassword base.TplName = "auth/reset_passwd"
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"
@ -561,3 +566,133 @@ func fromDisplayName(u *user_model.User) string {
}
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)()
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, 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)
assert.Contains(t, msgs[0].Body, manageUserURL)
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")
assertTranslatedLocale(t, msgs[0].Body, "mail.admin", "admin.users")
AssertTranslatedLocale(t, msgs[0].Body, "mail.admin", "admin.users")
called = true
})()
MailNewUser(ctx, users[1])
@ -71,7 +71,7 @@ func TestAdminNotificationMail_test(t *testing.T) {
t.Run("SendNotificationEmailOnNewUser_false", func(t *testing.T) {
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")
})()
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) {
defer mockMailSettings(nil)()
defer MockMailSettings(nil)()
doer, _, issue, comment := prepareMailerTest(t)
markup.Init(&markup.ProcessorHelper{
@ -117,7 +117,7 @@ func TestComposeIssueCommentMessage(t *testing.T) {
}
func TestComposeIssueMessage(t *testing.T) {
defer mockMailSettings(nil)()
defer MockMailSettings(nil)()
doer, _, issue, _ := prepareMailerTest(t)
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) {
defer mockMailSettings(nil)()
defer MockMailSettings(nil)()
assert.NoError(t, unittest.PrepareTestDatabase())
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
@ -160,7 +160,7 @@ func TestMailerIssueTemplate(t *testing.T) {
for _, s := range expected {
assert.Contains(t, wholemsg, s)
}
assertTranslatedLocale(t, wholemsg, "mail.issue")
AssertTranslatedLocale(t, wholemsg, "mail.issue")
}
testCompose := func(t *testing.T, ctx *mailCommentContext) *Message {
@ -241,7 +241,7 @@ func TestMailerIssueTemplate(t *testing.T) {
}
func TestTemplateSelection(t *testing.T) {
defer mockMailSettings(nil)()
defer MockMailSettings(nil)()
doer, repo, issue, comment := prepareMailerTest(t)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
@ -296,7 +296,7 @@ func TestTemplateSelection(t *testing.T) {
}
func TestTemplateServices(t *testing.T) {
defer mockMailSettings(nil)()
defer MockMailSettings(nil)()
doer, _, issue, comment := prepareMailerTest(t)
assert.NoError(t, issue.LoadRepo(db.DefaultContext))
@ -349,7 +349,7 @@ func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recip
}
func TestGenerateAdditionalHeaders(t *testing.T) {
defer mockMailSettings(nil)()
defer MockMailSettings(nil)()
doer, _, issue, _ := prepareMailerTest(t)
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) {
defer mockMailSettings(nil)()
defer MockMailSettings(nil)()
_, _, issue, comment := prepareMailerTest(t)
_, _, pullIssue, _ := prepareMailerTest(t)
pullIssue.IsPull = true

View file

@ -22,14 +22,14 @@ func TestMain(m *testing.M) {
unittest.MainTest(m)
}
func assertTranslatedLocale(t *testing.T, message string, prefixes ...string) {
func AssertTranslatedLocale(t *testing.T, message string, prefixes ...string) {
t.Helper()
for _, prefix := range prefixes {
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())
subjectTemplates, bodyTemplates = templates.Mailer(context.Background())
mailService := setting.Mailer{

View file

@ -12,6 +12,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"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
@ -163,7 +164,7 @@ func ReplaceInactivePrimaryEmail(ctx context.Context, oldEmail string, email *us
return err
}
err = user_model.MakeEmailPrimaryWithUser(ctx, user, email)
err = MakeEmailAddressPrimary(ctx, user, email, false)
if err != nil {
return err
}
@ -190,3 +191,42 @@ func DeleteEmailAddresses(ctx context.Context, u *user_model.User, emails []stri
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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAdminAddOrSetPrimaryEmailAddress(t *testing.T) {
@ -163,3 +164,15 @@ func TestDeleteEmailAddresses(t *testing.T) {
assert.Error(t, 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/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/mailer"
)
type UpdateOptions struct {
@ -220,5 +221,13 @@ func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions
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 (
"fmt"
"net/http"
"strconv"
"strings"
"testing"
@ -20,6 +21,7 @@ import (
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/services/mailer"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
@ -608,3 +610,140 @@ func TestUserPronouns(t *testing.T) {
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"})
})
}