diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index c7b09a38..f778ab79 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -43,9 +43,10 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler) group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler) + group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler) group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler) group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler) - group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler) + group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler) group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler) group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler) @@ -356,18 +357,63 @@ func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) { uc.createOneTimeAccessTokenHandler(c, true) } +// createAdminOneTimeAccessTokenHandler godoc +// @Summary Create one-time access token for user (admin) +// @Description Generate a one-time access token for a specific user (admin only) +// @Tags Users +// @Param id path string true "User ID" +// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options" +// @Success 201 {object} object "{ \"token\": \"string\" }" +// @Router /api/users/{id}/one-time-access-token [post] func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) { uc.createOneTimeAccessTokenHandler(c, false) } -func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) { - var input dto.OneTimeAccessEmailDto +// RequestOneTimeAccessEmailAsUnauthenticatedUserHandler godoc +// @Summary Request one-time access email +// @Description Request a one-time access email for unauthenticated users +// @Tags Users +// @Accept json +// @Produce json +// @Param body body dto.OneTimeAccessEmailAsUnauthenticatedUserDto true "Email request information" +// @Success 204 "No Content" +// @Router /api/one-time-access-email [post] +func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) { + var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto if err := c.ShouldBindJSON(&input); err != nil { _ = c.Error(err) return } - err := uc.userService.RequestOneTimeAccessEmail(c.Request.Context(), input.Email, input.RedirectPath) + err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath) + if err != nil { + _ = c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} + +// RequestOneTimeAccessEmailAsAdminHandler godoc +// @Summary Request one-time access email (admin) +// @Description Request a one-time access email for a specific user (admin only) +// @Tags Users +// @Accept json +// @Produce json +// @Param id path string true "User ID" +// @Param body body dto.OneTimeAccessEmailAsAdminDto true "Email request options" +// @Success 204 "No Content" +// @Router /api/users/{id}/one-time-access-email [post] +func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context) { + var input dto.OneTimeAccessEmailAsAdminDto + if err := c.ShouldBindJSON(&input); err != nil { + _ = c.Error(err) + return + } + + userID := c.Param("id") + + err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, input.ExpiresAt) if err != nil { _ = c.Error(err) return diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 91be6dd9..baef5847 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -12,37 +12,38 @@ type AppConfigVariableDto struct { } type AppConfigUpdateDto struct { - AppName string `json:"appName" binding:"required,min=1,max=30"` - SessionDuration string `json:"sessionDuration" binding:"required"` - EmailsVerified string `json:"emailsVerified" binding:"required"` - DisableAnimations string `json:"disableAnimations" binding:"required"` - AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` - SmtpHost string `json:"smtpHost"` - SmtpPort string `json:"smtpPort"` - SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` - SmtpUser string `json:"smtpUser"` - SmtpPassword string `json:"smtpPassword"` - SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"` - SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` - LdapEnabled string `json:"ldapEnabled" binding:"required"` - LdapUrl string `json:"ldapUrl"` - LdapBindDn string `json:"ldapBindDn"` - LdapBindPassword string `json:"ldapBindPassword"` - LdapBase string `json:"ldapBase"` - LdapUserSearchFilter string `json:"ldapUserSearchFilter"` - LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"` - LdapSkipCertVerify string `json:"ldapSkipCertVerify"` - LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"` - LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"` - LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` - LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` - LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` - LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` - LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` - LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` - LdapAttributeGroupName string `json:"ldapAttributeGroupName"` - LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"` - LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"` - EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"` - EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` + AppName string `json:"appName" binding:"required,min=1,max=30"` + SessionDuration string `json:"sessionDuration" binding:"required"` + EmailsVerified string `json:"emailsVerified" binding:"required"` + DisableAnimations string `json:"disableAnimations" binding:"required"` + AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` + SmtpHost string `json:"smtpHost"` + SmtpPort string `json:"smtpPort"` + SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` + SmtpUser string `json:"smtpUser"` + SmtpPassword string `json:"smtpPassword"` + SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"` + SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` + LdapEnabled string `json:"ldapEnabled" binding:"required"` + LdapUrl string `json:"ldapUrl"` + LdapBindDn string `json:"ldapBindDn"` + LdapBindPassword string `json:"ldapBindPassword"` + LdapBase string `json:"ldapBase"` + LdapUserSearchFilter string `json:"ldapUserSearchFilter"` + LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"` + LdapSkipCertVerify string `json:"ldapSkipCertVerify"` + LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"` + LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"` + LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` + LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` + LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` + LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` + LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` + LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` + LdapAttributeGroupName string `json:"ldapAttributeGroupName"` + LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"` + LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"` + EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"` + EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"` + EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` } diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index da984743..75919bf6 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -32,11 +32,15 @@ type OneTimeAccessTokenCreateDto struct { ExpiresAt time.Time `json:"expiresAt" binding:"required"` } -type OneTimeAccessEmailDto struct { +type OneTimeAccessEmailAsUnauthenticatedUserDto struct { Email string `json:"email" binding:"required,email"` RedirectPath string `json:"redirectPath"` } +type OneTimeAccessEmailAsAdminDto struct { + ExpiresAt time.Time `json:"expiresAt" binding:"required"` +} + type UserUpdateUserGroupDto struct { UserGroupIds []string `json:"userGroupIds" binding:"required"` } diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index 075e9996..0e03f62f 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -41,15 +41,16 @@ type AppConfig struct { LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal // Email - SmtpHost AppConfigVariable `key:"smtpHost"` - SmtpPort AppConfigVariable `key:"smtpPort"` - SmtpFrom AppConfigVariable `key:"smtpFrom"` - SmtpUser AppConfigVariable `key:"smtpUser"` - SmtpPassword AppConfigVariable `key:"smtpPassword"` - SmtpTls AppConfigVariable `key:"smtpTls"` - SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"` - EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"` - EmailOneTimeAccessEnabled AppConfigVariable `key:"emailOneTimeAccessEnabled,public"` // Public + SmtpHost AppConfigVariable `key:"smtpHost"` + SmtpPort AppConfigVariable `key:"smtpPort"` + SmtpFrom AppConfigVariable `key:"smtpFrom"` + SmtpUser AppConfigVariable `key:"smtpUser"` + SmtpPassword AppConfigVariable `key:"smtpPassword"` + SmtpTls AppConfigVariable `key:"smtpTls"` + SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"` + EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"` + EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public + EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public // LDAP LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public LdapUrl AppConfigVariable `key:"ldapUrl"` @@ -77,7 +78,7 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable { cfgValue := reflect.ValueOf(c).Elem() cfgType := cfgValue.Type() - res := make([]AppConfigVariable, cfgType.NumField()) + var res []AppConfigVariable for i := range cfgType.NumField() { field := cfgType.Field(i) @@ -94,10 +95,12 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable { fieldValue := cfgValue.Field(i) - res[i] = AppConfigVariable{ + appConfigVariable := AppConfigVariable{ Key: key, Value: fieldValue.FieldByName("Value").String(), } + + res = append(res, appConfigVariable) } return res diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index a5953a03..d6a511fe 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -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 { diff --git a/backend/internal/service/app_config_service_test.go b/backend/internal/service/app_config_service_test.go index ea7100c1..0304ab8b 100644 --- a/backend/internal/service/app_config_service_test.go +++ b/backend/internal/service/app_config_service_test.go @@ -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 diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 7b7325fd..1a2b1cd9 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -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 { diff --git a/backend/internal/service/email_service_templates.go b/backend/internal/service/email_service_templates.go index c140ab63..a7ca10a0 100644 --- a/backend/internal/service/email_service_templates.go +++ b/backend/internal/service/email_service_templates.go @@ -61,6 +61,7 @@ type OneTimeAccessTemplateData = struct { Code string LoginLink string LoginLinkWithCode string + ExpirationString string } type ApiKeyExpiringSoonTemplateData struct { diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index afd65231..b6347d04 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -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) diff --git a/backend/internal/utils/date_time_util.go b/backend/internal/utils/date_time_util.go new file mode 100644 index 00000000..5ed15766 --- /dev/null +++ b/backend/internal/utils/date_time_util.go @@ -0,0 +1,52 @@ +package utils + +import ( + "fmt" + "time" +) + +// DurationToString converts a time.Duration to a human-readable string. Respects minutes, hours and days. +func DurationToString(duration time.Duration) string { + // For a duration less than a day + if duration < 24*time.Hour { + hours := int(duration.Hours()) + mins := int(duration.Minutes()) % 60 + + switch hours { + case 0: + return fmt.Sprintf("%d minutes", mins) + case 1: + if mins == 0 { + return "1 hour" + } + return fmt.Sprintf("1 hour and %d minutes", mins) + default: + if mins == 0 { + return fmt.Sprintf("%d hours", hours) + } + return fmt.Sprintf("%d hours and %d minutes", hours, mins) + } + } else { + // For durations of a day or more + days := int(duration.Hours() / 24) + hours := int(duration.Hours()) % 24 + + switch hours { + case 0: + if days == 1 { + return "1 day" + } + return fmt.Sprintf("%d days", days) + case 1: + if days == 1 { + return "1 day and 1 hour" + } + return fmt.Sprintf("%d days and 1 hour", days) + default: + if days == 1 { + return fmt.Sprintf("1 day and %d hours", hours) + } + return fmt.Sprintf("%d days and %d hours", days, hours) + } + } +} diff --git a/backend/resources/email-templates/one-time-access_html.tmpl b/backend/resources/email-templates/one-time-access_html.tmpl index 2c150ee9..bd7ca059 100644 --- a/backend/resources/email-templates/one-time-access_html.tmpl +++ b/backend/resources/email-templates/one-time-access_html.tmpl @@ -8,7 +8,7 @@