feat: add ability to send login code via email (#457)

Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
This commit is contained in:
Elias Schneider
2025-04-20 18:32:40 +02:00
committed by GitHub
parent e571996cb5
commit fe1c4b18cd
22 changed files with 257 additions and 137 deletions

View File

@@ -73,7 +73,8 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
SmtpTls: model.AppConfigVariable{Value: "none"},
SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"},
EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
// LDAP
LdapEnabled: model.AppConfigVariable{Value: "false"},
LdapUrl: model.AppConfigVariable{},
@@ -151,11 +152,6 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
return nil, &common.UiConfigDisabledError{}
}
// If EmailLoginNotificationEnabled is set to false (explicitly), disable the EmailOneTimeAccessEnabled
if input.EmailLoginNotificationEnabled == "false" {
input.EmailOneTimeAccessEnabled = "false"
}
// Start the transaction
tx, err := s.updateAppConfigStartTransaction(ctx)
if err != nil {

View File

@@ -447,44 +447,6 @@ func TestUpdateAppConfig(t *testing.T) {
}
})
t.Run("auto disables EmailOneTimeAccessEnabled when EmailLoginNotificationEnabled is false", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// First enable both settings
err = service.UpdateAppConfigValues(t.Context(),
"emailLoginNotificationEnabled", "true",
"emailOneTimeAccessEnabled", "true",
)
require.NoError(t, err)
// Verify both are enabled
config := service.GetDbConfig()
require.True(t, config.EmailLoginNotificationEnabled.IsTrue())
require.True(t, config.EmailOneTimeAccessEnabled.IsTrue())
// Now disable EmailLoginNotificationEnabled
input := dto.AppConfigUpdateDto{
EmailLoginNotificationEnabled: "false",
// Don't set EmailOneTimeAccessEnabled, it should be auto-disabled
}
// Update config
_, err = service.UpdateAppConfig(t.Context(), input)
require.NoError(t, err)
// Verify EmailOneTimeAccessEnabled was automatically disabled
config = service.GetDbConfig()
require.False(t, config.EmailLoginNotificationEnabled.IsTrue())
require.False(t, config.EmailOneTimeAccessEnabled.IsTrue())
})
t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) {
// Save the original state and restore it after the test
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled

View File

@@ -104,10 +104,10 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
// so we use the domain of the from address instead (the same as Thunderbird does)
// if the address does not have an @ (which would be unusual), we use hostname
from_address := dbConfig.SmtpFrom.Value
fromAddress := dbConfig.SmtpFrom.Value
domain := ""
if strings.Contains(from_address, "@") {
domain = strings.Split(from_address, "@")[1]
if strings.Contains(fromAddress, "@") {
domain = strings.Split(fromAddress, "@")[1]
} else {
hostname, err := os.Hostname()
if err != nil {

View File

@@ -61,6 +61,7 @@ type OneTimeAccessTemplateData = struct {
Code string
LoginLink string
LoginLinkWithCode string
ExpirationString string
}
type ApiKeyExpiringSoonTemplateData struct {

View File

@@ -348,23 +348,24 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, nil
}
func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddress, redirectPath string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessEnabled.IsTrue()
func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, expiration time.Time) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
if isDisabled {
return &common.OneTimeAccessDisabledError{}
}
var user model.User
err := tx.
WithContext(ctx).
Where("email = ?", emailAddress).
First(&user).
Error
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", expiration)
}
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
if isDisabled {
return &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if err != nil {
// Do not return error if user not found to prevent email enumeration
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -374,7 +375,22 @@ func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddres
}
}
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, time.Now().Add(15*time.Minute), tx)
expiration := time.Now().Add(15 * time.Minute)
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, expiration)
}
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, expiration time.Time) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.GetUser(ctx, userID)
if err != nil {
return err
}
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, expiration, tx)
if err != nil {
return err
}
@@ -405,6 +421,7 @@ func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddres
Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(time.Until(expiration).Round(time.Second)),
})
if errInternal != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal)