mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-27 06:03:51 +03:00
Merge pull request '[SECURITY] Notify users about account security changes' (#4635) from gusted/sec-notify into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4635 Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
commit
ea1a0ebbc3
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
|
||||
GetSourceByName
|
||||
GetWebAuthnCredentialByID
|
||||
WebAuthnCredentials
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
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.
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
@ -39,6 +40,10 @@ const (
|
|||
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
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
|
|
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) {
|
||||
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
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
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 (
|
||||
"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'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…
Reference in a new issue