loading up the forgejo repo on tangled to test page performance

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

Gusted 4383da91 ded237ee

-1
.deadcode-out
··· 30 30 31 31 code.gitea.io/gitea/models/auth 32 32 GetSourceByName 33 - GetWebAuthnCredentialByID 34 33 WebAuthnCredentials 35 34 36 35 code.gitea.io/gitea/models/db
-54
models/user/email_address.go
··· 307 307 return UpdateUserCols(ctx, user, "rands") 308 308 } 309 309 310 - func MakeEmailPrimaryWithUser(ctx context.Context, user *User, email *EmailAddress) error { 311 - ctx, committer, err := db.TxContext(ctx) 312 - if err != nil { 313 - return err 314 - } 315 - defer committer.Close() 316 - sess := db.GetEngine(ctx) 317 - 318 - // 1. Update user table 319 - user.Email = email.Email 320 - if _, err = sess.ID(user.ID).Cols("email").Update(user); err != nil { 321 - return err 322 - } 323 - 324 - // 2. Update old primary email 325 - if _, err = sess.Where("uid=? AND is_primary=?", email.UID, true).Cols("is_primary").Update(&EmailAddress{ 326 - IsPrimary: false, 327 - }); err != nil { 328 - return err 329 - } 330 - 331 - // 3. update new primary email 332 - email.IsPrimary = true 333 - if _, err = sess.ID(email.ID).Cols("is_primary").Update(email); err != nil { 334 - return err 335 - } 336 - 337 - return committer.Commit() 338 - } 339 - 340 - // MakeEmailPrimary sets primary email address of given user. 341 - func MakeEmailPrimary(ctx context.Context, email *EmailAddress) error { 342 - has, err := db.GetEngine(ctx).Get(email) 343 - if err != nil { 344 - return err 345 - } else if !has { 346 - return ErrEmailAddressNotExist{Email: email.Email} 347 - } 348 - 349 - if !email.IsActivated { 350 - return ErrEmailNotActivated 351 - } 352 - 353 - user := &User{} 354 - has, err = db.GetEngine(ctx).ID(email.UID).Get(user) 355 - if err != nil { 356 - return err 357 - } else if !has { 358 - return ErrUserNotExist{UID: email.UID} 359 - } 360 - 361 - return MakeEmailPrimaryWithUser(ctx, user, email) 362 - } 363 - 364 310 // VerifyActiveEmailCode verifies active email code when active account 365 311 func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddress { 366 312 if user := GetVerifyUser(ctx, code); user != nil {
-34
models/user/email_address_test.go
··· 43 43 assert.False(t, isExist) 44 44 } 45 45 46 - func TestMakeEmailPrimary(t *testing.T) { 47 - assert.NoError(t, unittest.PrepareTestDatabase()) 48 - 49 - email := &user_model.EmailAddress{ 50 - Email: "user567890@example.com", 51 - } 52 - err := user_model.MakeEmailPrimary(db.DefaultContext, email) 53 - assert.Error(t, err) 54 - assert.EqualError(t, err, user_model.ErrEmailAddressNotExist{Email: email.Email}.Error()) 55 - 56 - email = &user_model.EmailAddress{ 57 - Email: "user11@example.com", 58 - } 59 - err = user_model.MakeEmailPrimary(db.DefaultContext, email) 60 - assert.Error(t, err) 61 - assert.EqualError(t, err, user_model.ErrEmailNotActivated.Error()) 62 - 63 - email = &user_model.EmailAddress{ 64 - Email: "user9999999@example.com", 65 - } 66 - err = user_model.MakeEmailPrimary(db.DefaultContext, email) 67 - assert.Error(t, err) 68 - assert.True(t, user_model.IsErrUserNotExist(err)) 69 - 70 - email = &user_model.EmailAddress{ 71 - Email: "user101@example.com", 72 - } 73 - err = user_model.MakeEmailPrimary(db.DefaultContext, email) 74 - assert.NoError(t, err) 75 - 76 - user, _ := user_model.GetUserByID(db.DefaultContext, int64(10)) 77 - assert.Equal(t, "user101@example.com", user.Email) 78 - } 79 - 80 46 func TestActivate(t *testing.T) { 81 47 assert.NoError(t, unittest.PrepareTestDatabase()) 82 48
+10 -5
models/user/user.go
··· 451 451 ) 452 452 453 453 // EmailTo returns a string suitable to be put into a e-mail `To:` header. 454 - func (u *User) EmailTo() string { 454 + func (u *User) EmailTo(overrideMail ...string) string { 455 455 sanitizedDisplayName := emailToReplacer.Replace(u.DisplayName()) 456 456 457 + email := u.Email 458 + if len(overrideMail) > 0 { 459 + email = overrideMail[0] 460 + } 461 + 457 462 // should be an edge case but nice to have 458 - if sanitizedDisplayName == u.Email { 459 - return u.Email 463 + if sanitizedDisplayName == email { 464 + return email 460 465 } 461 466 462 - address, err := mail.ParseAddress(fmt.Sprintf("%s <%s>", sanitizedDisplayName, u.Email)) 467 + address, err := mail.ParseAddress(fmt.Sprintf("%s <%s>", sanitizedDisplayName, email)) 463 468 if err != nil { 464 - return u.Email 469 + return email 465 470 } 466 471 467 472 return address.String()
+5
models/user/user_test.go
··· 625 625 assert.EqualValues(t, testCase.result, testUser.EmailTo()) 626 626 }) 627 627 } 628 + 629 + t.Run("Override user's email", func(t *testing.T) { 630 + testUser := &user_model.User{FullName: "Christine Jorgensen", Email: "christine@test.com"} 631 + assert.EqualValues(t, `"Christine Jorgensen" <christine@example.org>`, testUser.EmailTo("christine@example.org")) 632 + }) 628 633 } 629 634 630 635 func TestDisabledUserFeatures(t *testing.T) {
+18 -1
options/locale/locale_en-US.ini
··· 498 498 register_notify.text_3 = If someone else made this account for you, you will need to <a href="%s">set your password</a> first. 499 499 500 500 reset_password = Recover your account 501 - reset_password.text = If this was you, please click the following link to recover your account within <b>%s</b>: 501 + reset_password.text_1 = The password for your account was just changed. 502 + 503 + password_change.subject = Your password has been changed 504 + password_change.text_1 = The password for your account was just changed. 505 + 506 + primary_mail_change.subject = Your primary mail has been changed 507 + 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. 508 + 509 + totp_disabled.subject = TOTP has been disabled 510 + totp_disabled.text_1 = Time-based one-time password (TOTP) on your account was just disabled. 511 + 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. 512 + 513 + removed_security_key.subject = A security key has been removed 514 + removed_security_key.text_1 = Security key "%[1]s" has just been removed from your account. 515 + 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. 516 + 517 + account_security_caution.text_1 = If this was you, then you can safely ignore this mail. 518 + account_security_caution.text_2 = If this wasn't you, your account is compromised. Please contact the admins of this site. 502 519 503 520 register_success = Registration successful 504 521
+1
release-notes/4635.md
··· 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.
+9 -1
routers/web/user/setting/account.go
··· 104 104 105 105 // Make emailaddress primary. 106 106 if ctx.FormString("_method") == "PRIMARY" { 107 - if err := user_model.MakeEmailPrimary(ctx, &user_model.EmailAddress{ID: ctx.FormInt64("id")}); err != nil { 107 + id := ctx.FormInt64("id") 108 + email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, id) 109 + if err != nil { 110 + log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.Doer.ID, id, err) 111 + ctx.Redirect(setting.AppSubURL + "/user/settings/account") 112 + return 113 + } 114 + 115 + if err := user.MakeEmailAddressPrimary(ctx, ctx.Doer, email, true); err != nil { 108 116 ctx.ServerError("MakeEmailPrimary", err) 109 117 return 110 118 }
+6
routers/web/user/setting/security/2fa.go
··· 18 18 "code.gitea.io/gitea/modules/web" 19 19 "code.gitea.io/gitea/services/context" 20 20 "code.gitea.io/gitea/services/forms" 21 + "code.gitea.io/gitea/services/mailer" 21 22 22 23 "github.com/pquerna/otp" 23 24 "github.com/pquerna/otp/totp" ··· 75 76 ctx.Redirect(setting.AppSubURL + "/user/settings/security") 76 77 } 77 78 ctx.ServerError("SettingsTwoFactor: Failed to DeleteTwoFactorByID", err) 79 + return 80 + } 81 + 82 + if err := mailer.SendDisabledTOTP(ctx, ctx.Doer); err != nil { 83 + ctx.ServerError("SendDisabledTOTP", err) 78 84 return 79 85 } 80 86
+17
routers/web/user/setting/security/webauthn.go
··· 16 16 "code.gitea.io/gitea/modules/web" 17 17 "code.gitea.io/gitea/services/context" 18 18 "code.gitea.io/gitea/services/forms" 19 + "code.gitea.io/gitea/services/mailer" 19 20 20 21 "github.com/go-webauthn/webauthn/protocol" 21 22 "github.com/go-webauthn/webauthn/webauthn" ··· 112 113 // WebauthnDelete deletes an security key by id 113 114 func WebauthnDelete(ctx *context.Context) { 114 115 form := web.GetForm(ctx).(*forms.WebauthnDeleteForm) 116 + cred, err := auth.GetWebAuthnCredentialByID(ctx, form.ID) 117 + if err != nil || cred.UserID != ctx.Doer.ID { 118 + if err != nil && !auth.IsErrWebAuthnCredentialNotExist(err) { 119 + log.Error("GetWebAuthnCredentialByID: %v", err) 120 + } 121 + 122 + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") 123 + return 124 + } 125 + 115 126 if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil { 116 127 ctx.ServerError("GetWebAuthnCredentialByID", err) 117 128 return 118 129 } 130 + 131 + if err := mailer.SendRemovedSecurityKey(ctx, ctx.Doer, cred.Name); err != nil { 132 + ctx.ServerError("SendRemovedSecurityKey", err) 133 + return 134 + } 135 + 119 136 ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") 120 137 }
+139 -4
services/mailer/mail.go
··· 17 17 "time" 18 18 19 19 activities_model "code.gitea.io/gitea/models/activities" 20 + auth_model "code.gitea.io/gitea/models/auth" 20 21 issues_model "code.gitea.io/gitea/models/issues" 21 22 repo_model "code.gitea.io/gitea/models/repo" 22 23 user_model "code.gitea.io/gitea/models/user" ··· 35 36 ) 36 37 37 38 const ( 38 - mailAuthActivate base.TplName = "auth/activate" 39 - mailAuthActivateEmail base.TplName = "auth/activate_email" 40 - mailAuthResetPassword base.TplName = "auth/reset_passwd" 41 - mailAuthRegisterNotify base.TplName = "auth/register_notify" 39 + mailAuthActivate base.TplName = "auth/activate" 40 + mailAuthActivateEmail base.TplName = "auth/activate_email" 41 + mailAuthResetPassword base.TplName = "auth/reset_passwd" 42 + mailAuthRegisterNotify base.TplName = "auth/register_notify" 43 + mailAuthPasswordChange base.TplName = "auth/password_change" 44 + mailAuthPrimaryMailChange base.TplName = "auth/primary_mail_change" 45 + mailAuth2faDisabled base.TplName = "auth/2fa_disabled" 46 + mailAuthRemovedSecurityKey base.TplName = "auth/removed_security_key" 42 47 43 48 mailNotifyCollaborator base.TplName = "notify/collaborator" 44 49 ··· 561 566 } 562 567 return u.GetCompleteName() 563 568 } 569 + 570 + // SendPasswordChange informs the user on their primary email address that 571 + // their password was changed. 572 + func SendPasswordChange(u *user_model.User) error { 573 + if setting.MailService == nil { 574 + return nil 575 + } 576 + locale := translation.NewLocale(u.Language) 577 + 578 + data := map[string]any{ 579 + "locale": locale, 580 + "DisplayName": u.DisplayName(), 581 + "Username": u.Name, 582 + "Language": locale.Language(), 583 + } 584 + 585 + var content bytes.Buffer 586 + 587 + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthPasswordChange), data); err != nil { 588 + return err 589 + } 590 + 591 + msg := NewMessage(u.EmailTo(), locale.TrString("mail.password_change.subject"), content.String()) 592 + msg.Info = fmt.Sprintf("UID: %d, password change notification", u.ID) 593 + 594 + SendAsync(msg) 595 + return nil 596 + } 597 + 598 + // SendPrimaryMailChange informs the user on their old primary email address 599 + // that it's no longer used as primary mail and will no longer receive 600 + // notification on that email address. 601 + func SendPrimaryMailChange(u *user_model.User, oldPrimaryEmail string) error { 602 + if setting.MailService == nil { 603 + return nil 604 + } 605 + locale := translation.NewLocale(u.Language) 606 + 607 + data := map[string]any{ 608 + "locale": locale, 609 + "NewPrimaryMail": u.Email, 610 + "DisplayName": u.DisplayName(), 611 + "Username": u.Name, 612 + "Language": locale.Language(), 613 + } 614 + 615 + var content bytes.Buffer 616 + 617 + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthPrimaryMailChange), data); err != nil { 618 + return err 619 + } 620 + 621 + msg := NewMessage(u.EmailTo(oldPrimaryEmail), locale.TrString("mail.primary_mail_change.subject"), content.String()) 622 + msg.Info = fmt.Sprintf("UID: %d, primary email change notification", u.ID) 623 + 624 + SendAsync(msg) 625 + return nil 626 + } 627 + 628 + // SendDisabledTOTP informs the user that their totp has been disabled. 629 + func SendDisabledTOTP(ctx context.Context, u *user_model.User) error { 630 + if setting.MailService == nil { 631 + return nil 632 + } 633 + locale := translation.NewLocale(u.Language) 634 + 635 + hasWebAuthn, err := auth_model.HasWebAuthnRegistrationsByUID(ctx, u.ID) 636 + if err != nil { 637 + return err 638 + } 639 + 640 + data := map[string]any{ 641 + "locale": locale, 642 + "HasWebAuthn": hasWebAuthn, 643 + "DisplayName": u.DisplayName(), 644 + "Username": u.Name, 645 + "Language": locale.Language(), 646 + } 647 + 648 + var content bytes.Buffer 649 + 650 + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuth2faDisabled), data); err != nil { 651 + return err 652 + } 653 + 654 + msg := NewMessage(u.EmailTo(), locale.TrString("mail.totp_disabled.subject"), content.String()) 655 + msg.Info = fmt.Sprintf("UID: %d, 2fa disabled notification", u.ID) 656 + 657 + SendAsync(msg) 658 + return nil 659 + } 660 + 661 + // SendRemovedWebAuthn informs the user that one of their security keys has been removed. 662 + func SendRemovedSecurityKey(ctx context.Context, u *user_model.User, securityKeyName string) error { 663 + if setting.MailService == nil { 664 + return nil 665 + } 666 + locale := translation.NewLocale(u.Language) 667 + 668 + hasWebAuthn, err := auth_model.HasWebAuthnRegistrationsByUID(ctx, u.ID) 669 + if err != nil { 670 + return err 671 + } 672 + hasTOTP, err := auth_model.HasTwoFactorByUID(ctx, u.ID) 673 + if err != nil { 674 + return err 675 + } 676 + 677 + data := map[string]any{ 678 + "locale": locale, 679 + "HasWebAuthn": hasWebAuthn, 680 + "HasTOTP": hasTOTP, 681 + "SecurityKeyName": securityKeyName, 682 + "DisplayName": u.DisplayName(), 683 + "Username": u.Name, 684 + "Language": locale.Language(), 685 + } 686 + 687 + var content bytes.Buffer 688 + 689 + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRemovedSecurityKey), data); err != nil { 690 + return err 691 + } 692 + 693 + msg := NewMessage(u.EmailTo(), locale.TrString("mail.removed_security_key.subject"), content.String()) 694 + msg.Info = fmt.Sprintf("UID: %d, security key removed notification", u.ID) 695 + 696 + SendAsync(msg) 697 + return nil 698 + }
+3 -3
services/mailer/mail_admin_new_user_test.go
··· 55 55 defer test.MockVariableValue(&setting.Admin.SendNotificationEmailOnNewUser, true)() 56 56 57 57 called := false 58 - defer mockMailSettings(func(msgs ...*Message) { 58 + defer MockMailSettings(func(msgs ...*Message) { 59 59 assert.Equal(t, len(msgs), 1, "Test provides only one admin user, so only one email must be sent") 60 60 assert.Equal(t, msgs[0].To, users[0].Email, "checks if the recipient is the admin of the instance") 61 61 manageUserURL := setting.AppURL + "admin/users/" + strconv.FormatInt(users[1].ID, 10) 62 62 assert.Contains(t, msgs[0].Body, manageUserURL) 63 63 assert.Contains(t, msgs[0].Body, users[1].HTMLURL()) 64 64 assert.Contains(t, msgs[0].Body, users[1].Name, "user name of the newly created user") 65 - assertTranslatedLocale(t, msgs[0].Body, "mail.admin", "admin.users") 65 + AssertTranslatedLocale(t, msgs[0].Body, "mail.admin", "admin.users") 66 66 called = true 67 67 })() 68 68 MailNewUser(ctx, users[1]) ··· 71 71 72 72 t.Run("SendNotificationEmailOnNewUser_false", func(t *testing.T) { 73 73 defer test.MockVariableValue(&setting.Admin.SendNotificationEmailOnNewUser, false)() 74 - defer mockMailSettings(func(msgs ...*Message) { 74 + defer MockMailSettings(func(msgs ...*Message) { 75 75 assert.Equal(t, 1, 0, "this shouldn't execute. MailNewUser must exit early since SEND_NOTIFICATION_EMAIL_ON_NEW_USER is disabled") 76 76 })() 77 77 MailNewUser(ctx, users[1])
+60
services/mailer/mail_auth_test.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package mailer_test 5 + 6 + import ( 7 + "testing" 8 + 9 + "code.gitea.io/gitea/models/db" 10 + "code.gitea.io/gitea/models/unittest" 11 + user_model "code.gitea.io/gitea/models/user" 12 + "code.gitea.io/gitea/modules/optional" 13 + "code.gitea.io/gitea/modules/translation" 14 + "code.gitea.io/gitea/services/mailer" 15 + user_service "code.gitea.io/gitea/services/user" 16 + 17 + "github.com/stretchr/testify/assert" 18 + "github.com/stretchr/testify/require" 19 + ) 20 + 21 + func TestPasswordChangeMail(t *testing.T) { 22 + defer require.NoError(t, unittest.PrepareTestDatabase()) 23 + 24 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 25 + called := false 26 + defer mailer.MockMailSettings(func(msgs ...*mailer.Message) { 27 + assert.Len(t, msgs, 1) 28 + assert.Equal(t, user.EmailTo(), msgs[0].To) 29 + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.password_change.subject"), msgs[0].Subject) 30 + mailer.AssertTranslatedLocale(t, msgs[0].Body, "mail.password_change.text_1", "mail.password_change.text_2", "mail.password_change.text_3") 31 + called = true 32 + })() 33 + 34 + require.NoError(t, user_service.UpdateAuth(db.DefaultContext, user, &user_service.UpdateAuthOptions{Password: optional.Some("NewPasswordYolo!")})) 35 + assert.True(t, called) 36 + } 37 + 38 + func TestPrimaryMailChange(t *testing.T) { 39 + defer require.NoError(t, unittest.PrepareTestDatabase()) 40 + 41 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 42 + firstEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 3, UID: user.ID, IsPrimary: true}) 43 + secondEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}, "is_primary = false") 44 + 45 + called := false 46 + defer mailer.MockMailSettings(func(msgs ...*mailer.Message) { 47 + assert.False(t, called) 48 + assert.Len(t, msgs, 1) 49 + assert.Equal(t, user.EmailTo(firstEmail.Email), msgs[0].To) 50 + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.primary_mail_change.subject"), msgs[0].Subject) 51 + assert.Contains(t, msgs[0].Body, secondEmail.Email) 52 + mailer.AssertTranslatedLocale(t, msgs[0].Body, "mail.primary_mail_change.text_1", "mail.primary_mail_change.text_2", "mail.primary_mail_change.text_3") 53 + called = true 54 + })() 55 + 56 + require.NoError(t, user_service.MakeEmailAddressPrimary(db.DefaultContext, user, secondEmail, true)) 57 + assert.True(t, called) 58 + 59 + require.NoError(t, user_service.MakeEmailAddressPrimary(db.DefaultContext, user, firstEmail, false)) 60 + }
+8 -8
services/mailer/mail_test.go
··· 62 62 } 63 63 64 64 func TestComposeIssueCommentMessage(t *testing.T) { 65 - defer mockMailSettings(nil)() 65 + defer MockMailSettings(nil)() 66 66 doer, _, issue, comment := prepareMailerTest(t) 67 67 68 68 markup.Init(&markup.ProcessorHelper{ ··· 117 117 } 118 118 119 119 func TestComposeIssueMessage(t *testing.T) { 120 - defer mockMailSettings(nil)() 120 + defer MockMailSettings(nil)() 121 121 doer, _, issue, _ := prepareMailerTest(t) 122 122 123 123 recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}} ··· 146 146 } 147 147 148 148 func TestMailerIssueTemplate(t *testing.T) { 149 - defer mockMailSettings(nil)() 149 + defer MockMailSettings(nil)() 150 150 assert.NoError(t, unittest.PrepareTestDatabase()) 151 151 152 152 doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) ··· 160 160 for _, s := range expected { 161 161 assert.Contains(t, wholemsg, s) 162 162 } 163 - assertTranslatedLocale(t, wholemsg, "mail.issue") 163 + AssertTranslatedLocale(t, wholemsg, "mail.issue") 164 164 } 165 165 166 166 testCompose := func(t *testing.T, ctx *mailCommentContext) *Message { ··· 241 241 } 242 242 243 243 func TestTemplateSelection(t *testing.T) { 244 - defer mockMailSettings(nil)() 244 + defer MockMailSettings(nil)() 245 245 doer, repo, issue, comment := prepareMailerTest(t) 246 246 recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}} 247 247 ··· 296 296 } 297 297 298 298 func TestTemplateServices(t *testing.T) { 299 - defer mockMailSettings(nil)() 299 + defer MockMailSettings(nil)() 300 300 doer, _, issue, comment := prepareMailerTest(t) 301 301 assert.NoError(t, issue.LoadRepo(db.DefaultContext)) 302 302 ··· 349 349 } 350 350 351 351 func TestGenerateAdditionalHeaders(t *testing.T) { 352 - defer mockMailSettings(nil)() 352 + defer MockMailSettings(nil)() 353 353 doer, _, issue, _ := prepareMailerTest(t) 354 354 355 355 ctx := &mailCommentContext{Context: context.TODO() /* TODO: use a correct context */, Issue: issue, Doer: doer} ··· 382 382 } 383 383 384 384 func Test_createReference(t *testing.T) { 385 - defer mockMailSettings(nil)() 385 + defer MockMailSettings(nil)() 386 386 _, _, issue, comment := prepareMailerTest(t) 387 387 _, _, pullIssue, _ := prepareMailerTest(t) 388 388 pullIssue.IsPull = true
+2 -2
services/mailer/main_test.go
··· 22 22 unittest.MainTest(m) 23 23 } 24 24 25 - func assertTranslatedLocale(t *testing.T, message string, prefixes ...string) { 25 + func AssertTranslatedLocale(t *testing.T, message string, prefixes ...string) { 26 26 t.Helper() 27 27 for _, prefix := range prefixes { 28 28 assert.NotContains(t, message, prefix, "there is an untranslated locale prefix") 29 29 } 30 30 } 31 31 32 - func mockMailSettings(send func(msgs ...*Message)) func() { 32 + func MockMailSettings(send func(msgs ...*Message)) func() { 33 33 translation.InitLocales(context.Background()) 34 34 subjectTemplates, bodyTemplates = templates.Mailer(context.Background()) 35 35 mailService := setting.Mailer{
+41 -1
services/user/email.go
··· 12 12 user_model "code.gitea.io/gitea/models/user" 13 13 "code.gitea.io/gitea/modules/setting" 14 14 "code.gitea.io/gitea/modules/util" 15 + "code.gitea.io/gitea/services/mailer" 15 16 ) 16 17 17 18 // AdminAddOrSetPrimaryEmailAddress is used by admins to add or set a user's primary email address ··· 163 164 return err 164 165 } 165 166 166 - err = user_model.MakeEmailPrimaryWithUser(ctx, user, email) 167 + err = MakeEmailAddressPrimary(ctx, user, email, false) 167 168 if err != nil { 168 169 return err 169 170 } ··· 190 191 191 192 return nil 192 193 } 194 + 195 + func MakeEmailAddressPrimary(ctx context.Context, u *user_model.User, newPrimaryEmail *user_model.EmailAddress, notify bool) error { 196 + ctx, committer, err := db.TxContext(ctx) 197 + if err != nil { 198 + return err 199 + } 200 + defer committer.Close() 201 + sess := db.GetEngine(ctx) 202 + 203 + oldPrimaryEmail := u.Email 204 + 205 + // 1. Update user table 206 + u.Email = newPrimaryEmail.Email 207 + if _, err = sess.ID(u.ID).Cols("email").Update(u); err != nil { 208 + return err 209 + } 210 + 211 + // 2. Update old primary email 212 + if _, err = sess.Where("uid=? AND is_primary=?", u.ID, true).Cols("is_primary").Update(&user_model.EmailAddress{ 213 + IsPrimary: false, 214 + }); err != nil { 215 + return err 216 + } 217 + 218 + // 3. update new primary email 219 + newPrimaryEmail.IsPrimary = true 220 + if _, err = sess.ID(newPrimaryEmail.ID).Cols("is_primary").Update(newPrimaryEmail); err != nil { 221 + return err 222 + } 223 + 224 + if err := committer.Commit(); err != nil { 225 + return err 226 + } 227 + 228 + if notify { 229 + return mailer.SendPrimaryMailChange(u, oldPrimaryEmail) 230 + } 231 + return nil 232 + }
+13
services/user/email_test.go
··· 14 14 15 15 "github.com/gobwas/glob" 16 16 "github.com/stretchr/testify/assert" 17 + "github.com/stretchr/testify/require" 17 18 ) 18 19 19 20 func TestAdminAddOrSetPrimaryEmailAddress(t *testing.T) { ··· 163 164 assert.Error(t, err) 164 165 assert.True(t, user_model.IsErrPrimaryEmailCannotDelete(err)) 165 166 } 167 + 168 + func TestMakeEmailAddressPrimary(t *testing.T) { 169 + require.NoError(t, unittest.PrepareTestDatabase()) 170 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 171 + newPrimaryEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}, "is_primary = false") 172 + 173 + require.NoError(t, MakeEmailAddressPrimary(db.DefaultContext, user, newPrimaryEmail, false)) 174 + 175 + unittest.AssertExistsIf(t, true, &user_model.User{ID: 2, Email: newPrimaryEmail.Email}) 176 + unittest.AssertExistsIf(t, true, &user_model.EmailAddress{ID: 3, UID: user.ID}, "is_primary = false") 177 + unittest.AssertExistsIf(t, true, &user_model.EmailAddress{ID: 35, UID: user.ID, IsPrimary: true}) 178 + }
+10 -1
services/user/update.go
··· 14 14 "code.gitea.io/gitea/modules/optional" 15 15 "code.gitea.io/gitea/modules/setting" 16 16 "code.gitea.io/gitea/modules/structs" 17 + "code.gitea.io/gitea/services/mailer" 17 18 ) 18 19 19 20 type UpdateOptions struct { ··· 220 221 u.ProhibitLogin = opts.ProhibitLogin.Value() 221 222 } 222 223 223 - return user_model.UpdateUserCols(ctx, u, "login_type", "login_source", "login_name", "passwd", "passwd_hash_algo", "salt", "must_change_password", "prohibit_login") 224 + 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 { 225 + return err 226 + } 227 + 228 + if opts.Password.Has() { 229 + return mailer.SendPasswordChange(u) 230 + } 231 + 232 + return nil 224 233 }
+15
templates/mail/auth/2fa_disabled.tmpl
··· 1 + <head> 2 + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> 3 + <meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no"> 4 + </head> 5 + 6 + <body> 7 + <p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p><br> 8 + <p>{{.locale.Tr "mail.totp_disabled.text_1"}}</p><br> 9 + {{if not .HasWebAuthn}}<p>{{.locale.Tr "mail.totp_disabled.no_2fa"}}</p><br>{{end}} 10 + <p>{{.locale.Tr "mail.account_security_caution.text_1"}}</p><br> 11 + <p>{{.locale.Tr "mail.account_security_caution.text_2"}}</p><br> 12 + 13 + {{template "common/footer_simple" .}} 14 + </body> 15 + </html>
+16
templates/mail/auth/password_change.tmpl
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> 5 + <meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no"> 6 + </head> 7 + 8 + <body> 9 + <p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p><br> 10 + <p>{{.locale.Tr "mail.password_change.text_1"}}</p><br> 11 + <p>{{.locale.Tr "mail.account_security_caution.text_1"}}</p><br> 12 + <p>{{.locale.Tr "mail.account_security_caution.text_2"}}</p><br> 13 + 14 + {{template "common/footer_simple" .}} 15 + </body> 16 + </html>
+14
templates/mail/auth/primary_mail_change.tmpl
··· 1 + <head> 2 + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> 3 + <meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no"> 4 + </head> 5 + 6 + <body> 7 + <p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p><br> 8 + <p>{{.locale.Tr "mail.primary_mail_change.text_1" .NewPrimaryMail}}</p><br> 9 + <p>{{.locale.Tr "mail.account_security_caution.text_1"}}</p><br> 10 + <p>{{.locale.Tr "mail.account_security_caution.text_2"}}</p><br> 11 + 12 + {{template "common/footer_simple" .}} 13 + </body> 14 + </html>
+15
templates/mail/auth/removed_security_key.tmpl
··· 1 + <head> 2 + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> 3 + <meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no"> 4 + </head> 5 + 6 + <body> 7 + <p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p><br> 8 + <p>{{.locale.Tr "mail.removed_security_key.text_1" .SecurityKeyName}}</p><br> 9 + {{if and (not .HasWebAuthn) (not .HasTOTP)}}<p>{{.locale.Tr "mail.removed_security_key.no_2fa"}}</p><br>{{end}} 10 + <p>{{.locale.Tr "mail.account_security_caution.text_1"}}</p><br> 11 + <p>{{.locale.Tr "mail.account_security_caution.text_2"}}</p><br> 12 + 13 + {{template "common/footer_simple" .}} 14 + </body> 15 + </html>
+1
templates/mail/common/footer_simple.tmpl
··· 1 + <p><a target="_blank" rel="noopener noreferrer" href="{{$.AppUrl}}">{{AppName}}</a></p>
+139
tests/integration/user_test.go
··· 7 7 import ( 8 8 "fmt" 9 9 "net/http" 10 + "strconv" 10 11 "strings" 11 12 "testing" 12 13 ··· 20 21 api "code.gitea.io/gitea/modules/structs" 21 22 "code.gitea.io/gitea/modules/test" 22 23 "code.gitea.io/gitea/modules/translation" 24 + "code.gitea.io/gitea/services/mailer" 23 25 "code.gitea.io/gitea/tests" 24 26 25 27 "github.com/stretchr/testify/assert" ··· 608 610 assert.EqualValues(t, userName, "user2") 609 611 }) 610 612 } 613 + 614 + func TestUserTOTPMail(t *testing.T) { 615 + defer tests.PrepareTestEnv(t)() 616 + 617 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 618 + session := loginUser(t, user.Name) 619 + 620 + t.Run("No security keys", func(t *testing.T) { 621 + defer tests.PrintCurrentTest(t)() 622 + 623 + called := false 624 + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { 625 + assert.Len(t, msgs, 1) 626 + assert.Equal(t, user.EmailTo(), msgs[0].To) 627 + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_disabled.subject"), msgs[0].Subject) 628 + assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_disabled.no_2fa")) 629 + called = true 630 + })() 631 + 632 + unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID}) 633 + req := NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/disable", map[string]string{ 634 + "_csrf": GetCSRF(t, session, "/user/settings/security"), 635 + }) 636 + session.MakeRequest(t, req, http.StatusSeeOther) 637 + 638 + assert.True(t, called) 639 + unittest.AssertExistsIf(t, false, &auth_model.TwoFactor{UID: user.ID}) 640 + }) 641 + 642 + t.Run("with security keys", func(t *testing.T) { 643 + defer tests.PrintCurrentTest(t)() 644 + 645 + called := false 646 + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { 647 + assert.Len(t, msgs, 1) 648 + assert.Equal(t, user.EmailTo(), msgs[0].To) 649 + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_disabled.subject"), msgs[0].Subject) 650 + assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_disabled.no_2fa")) 651 + called = true 652 + })() 653 + 654 + unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID}) 655 + unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID}) 656 + req := NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/disable", map[string]string{ 657 + "_csrf": GetCSRF(t, session, "/user/settings/security"), 658 + }) 659 + session.MakeRequest(t, req, http.StatusSeeOther) 660 + 661 + assert.True(t, called) 662 + unittest.AssertExistsIf(t, false, &auth_model.TwoFactor{UID: user.ID}) 663 + }) 664 + } 665 + 666 + func TestUserSecurityKeyMail(t *testing.T) { 667 + defer tests.PrepareTestEnv(t)() 668 + 669 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) 670 + session := loginUser(t, user.Name) 671 + 672 + t.Run("Normal", func(t *testing.T) { 673 + defer tests.PrintCurrentTest(t)() 674 + 675 + called := false 676 + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { 677 + assert.Len(t, msgs, 1) 678 + assert.Equal(t, user.EmailTo(), msgs[0].To) 679 + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject) 680 + assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa")) 681 + assert.Contains(t, msgs[0].Body, "Little Bobby Tables&#39;s primary key") 682 + called = true 683 + })() 684 + 685 + unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"}) 686 + id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID 687 + req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{ 688 + "_csrf": GetCSRF(t, session, "/user/settings/security"), 689 + "id": strconv.FormatInt(id, 10), 690 + }) 691 + session.MakeRequest(t, req, http.StatusOK) 692 + 693 + assert.True(t, called) 694 + unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID}) 695 + }) 696 + 697 + t.Run("With TOTP", func(t *testing.T) { 698 + defer tests.PrintCurrentTest(t)() 699 + 700 + called := false 701 + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { 702 + assert.Len(t, msgs, 1) 703 + assert.Equal(t, user.EmailTo(), msgs[0].To) 704 + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject) 705 + assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa")) 706 + assert.Contains(t, msgs[0].Body, "Little Bobby Tables&#39;s primary key") 707 + called = true 708 + })() 709 + 710 + unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"}) 711 + id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID 712 + unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID}) 713 + req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{ 714 + "_csrf": GetCSRF(t, session, "/user/settings/security"), 715 + "id": strconv.FormatInt(id, 10), 716 + }) 717 + session.MakeRequest(t, req, http.StatusOK) 718 + 719 + assert.True(t, called) 720 + unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID}) 721 + }) 722 + 723 + t.Run("Two security keys", func(t *testing.T) { 724 + defer tests.PrintCurrentTest(t)() 725 + 726 + called := false 727 + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { 728 + assert.Len(t, msgs, 1) 729 + assert.Equal(t, user.EmailTo(), msgs[0].To) 730 + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject) 731 + assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa")) 732 + assert.Contains(t, msgs[0].Body, "Little Bobby Tables&#39;s primary key") 733 + called = true 734 + })() 735 + 736 + unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"}) 737 + id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID 738 + unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's evil key"}) 739 + req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{ 740 + "_csrf": GetCSRF(t, session, "/user/settings/security"), 741 + "id": strconv.FormatInt(id, 10), 742 + }) 743 + session.MakeRequest(t, req, http.StatusOK) 744 + 745 + assert.True(t, called) 746 + unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"}) 747 + unittest.AssertExistsIf(t, true, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's evil key"}) 748 + }) 749 + }