[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:
parent
ded237ee77
commit
4383da91bd
24 changed files with 543 additions and 116 deletions
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
1
release-notes/4635.md
Normal 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.
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -35,10 +36,14 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
mailAuthActivate base.TplName = "auth/activate"
|
mailAuthActivate base.TplName = "auth/activate"
|
||||||
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
|
||||||
|
}
|
||||||
|
|
|
@ -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])
|
||||||
|
|
60
services/mailer/mail_auth_test.go
Normal file
60
services/mailer/mail_auth_test.go
Normal 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))
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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{
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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})
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
15
templates/mail/auth/2fa_disabled.tmpl
Normal file
15
templates/mail/auth/2fa_disabled.tmpl
Normal 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>
|
16
templates/mail/auth/password_change.tmpl
Normal file
16
templates/mail/auth/password_change.tmpl
Normal 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>
|
14
templates/mail/auth/primary_mail_change.tmpl
Normal file
14
templates/mail/auth/primary_mail_change.tmpl
Normal 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>
|
15
templates/mail/auth/removed_security_key.tmpl
Normal file
15
templates/mail/auth/removed_security_key.tmpl
Normal 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>
|
1
templates/mail/common/footer_simple.tmpl
Normal file
1
templates/mail/common/footer_simple.tmpl
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<p><a target="_blank" rel="noopener noreferrer" href="{{$.AppUrl}}">{{AppName}}</a></p>
|
|
@ -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'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'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'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"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue