mirror of
https://github.com/drakkan/sftpgo.git
synced 2025-12-07 14:50:55 +03:00
eventmanager: add password notification check action
this action allow to send an email notification to users whose password is about to expire Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
@@ -1643,9 +1643,9 @@ func executeFsRuleAction(c dataprovider.EventActionFilesystemConfig, conditions
|
||||
}
|
||||
}
|
||||
|
||||
func executeQuotaResetForUser(user dataprovider.User) error {
|
||||
func executeQuotaResetForUser(user *dataprovider.User) error {
|
||||
if err := user.LoadAndApplyGroupSettings(); err != nil {
|
||||
eventManagerLog(logger.LevelDebug, "skipping scheduled quota reset for user %s, cannot apply group settings: %v",
|
||||
eventManagerLog(logger.LevelError, "skipping scheduled quota reset for user %s, cannot apply group settings: %v",
|
||||
user.Username, err)
|
||||
return err
|
||||
}
|
||||
@@ -1660,7 +1660,7 @@ func executeQuotaResetForUser(user dataprovider.User) error {
|
||||
eventManagerLog(logger.LevelError, "error scanning quota for user %q: %v", user.Username, err)
|
||||
return fmt.Errorf("error scanning quota for user %q: %w", user.Username, err)
|
||||
}
|
||||
err = dataprovider.UpdateUserQuota(&user, numFiles, size, true)
|
||||
err = dataprovider.UpdateUserQuota(user, numFiles, size, true)
|
||||
if err != nil {
|
||||
eventManagerLog(logger.LevelError, "error updating quota for user %q: %v", user.Username, err)
|
||||
return fmt.Errorf("error updating quota for user %q: %w", user.Username, err)
|
||||
@@ -1685,7 +1685,7 @@ func executeUsersQuotaResetRuleAction(conditions dataprovider.ConditionOptions,
|
||||
}
|
||||
}
|
||||
executed++
|
||||
if err = executeQuotaResetForUser(user); err != nil {
|
||||
if err = executeQuotaResetForUser(&user); err != nil {
|
||||
params.AddError(err)
|
||||
failedResets = append(failedResets, user.Username)
|
||||
continue
|
||||
@@ -1789,7 +1789,7 @@ func executeDataRetentionCheckForUser(user dataprovider.User, folders []dataprov
|
||||
params *EventParams, actionName string,
|
||||
) error {
|
||||
if err := user.LoadAndApplyGroupSettings(); err != nil {
|
||||
eventManagerLog(logger.LevelDebug, "skipping scheduled retention check for user %s, cannot apply group settings: %v",
|
||||
eventManagerLog(logger.LevelError, "skipping scheduled retention check for user %s, cannot apply group settings: %v",
|
||||
user.Username, err)
|
||||
return err
|
||||
}
|
||||
@@ -1850,9 +1850,9 @@ func executeDataRetentionCheckRuleAction(config dataprovider.EventActionDataRete
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeMetadataCheckForUser(user dataprovider.User) error {
|
||||
func executeMetadataCheckForUser(user *dataprovider.User) error {
|
||||
if err := user.LoadAndApplyGroupSettings(); err != nil {
|
||||
eventManagerLog(logger.LevelDebug, "skipping scheduled quota reset for user %s, cannot apply group settings: %v",
|
||||
eventManagerLog(logger.LevelError, "skipping scheduled quota reset for user %s, cannot apply group settings: %v",
|
||||
user.Username, err)
|
||||
return err
|
||||
}
|
||||
@@ -1886,7 +1886,7 @@ func executeMetadataCheckRuleAction(conditions dataprovider.ConditionOptions, pa
|
||||
}
|
||||
}
|
||||
executed++
|
||||
if err = executeMetadataCheckForUser(user); err != nil {
|
||||
if err = executeMetadataCheckForUser(&user); err != nil {
|
||||
params.AddError(err)
|
||||
failures = append(failures, user.Username)
|
||||
continue
|
||||
@@ -1902,6 +1902,72 @@ func executeMetadataCheckRuleAction(conditions dataprovider.ConditionOptions, pa
|
||||
return nil
|
||||
}
|
||||
|
||||
func executePwdExpirationCheckForUser(user *dataprovider.User, config dataprovider.EventActionPasswordExpiration) error {
|
||||
if err := user.LoadAndApplyGroupSettings(); err != nil {
|
||||
eventManagerLog(logger.LevelError, "skipping password expiration check for user %s, cannot apply group settings: %v",
|
||||
user.Username, err)
|
||||
return err
|
||||
}
|
||||
if user.Filters.PasswordExpiration == 0 {
|
||||
eventManagerLog(logger.LevelDebug, "password expiration not set for user %q skipping check", user.Username)
|
||||
return nil
|
||||
}
|
||||
days := user.PasswordExpiresIn()
|
||||
if days > config.Threshold {
|
||||
eventManagerLog(logger.LevelDebug, "password for user %q expires in %d days, threshold %d, no need to notify",
|
||||
user.Username, days, config.Threshold)
|
||||
return nil
|
||||
}
|
||||
body := new(bytes.Buffer)
|
||||
data := make(map[string]any)
|
||||
data["Username"] = user.Username
|
||||
data["Days"] = days
|
||||
if err := smtp.RenderPasswordExpirationTemplate(body, data); err != nil {
|
||||
eventManagerLog(logger.LevelError, "unable to notify password expiration for user %s: %v",
|
||||
user.Username, err)
|
||||
return err
|
||||
}
|
||||
subject := "SFTPGo password expiration notification"
|
||||
startTime := time.Now()
|
||||
if err := smtp.SendEmail([]string{user.Email}, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
|
||||
eventManagerLog(logger.LevelError, "unable to notify password expiration for user %s: %v, elapsed: %s",
|
||||
user.Username, err, time.Since(startTime))
|
||||
return err
|
||||
}
|
||||
eventManagerLog(logger.LevelDebug, "password expiration email sent to user %s, days: %d, elapsed: %s",
|
||||
user.Username, days, time.Since(startTime))
|
||||
return nil
|
||||
}
|
||||
|
||||
func executePwdExpirationCheckRuleAction(config dataprovider.EventActionPasswordExpiration, conditions dataprovider.ConditionOptions,
|
||||
params *EventParams) error {
|
||||
users, err := params.getUsers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to get users: %w", err)
|
||||
}
|
||||
var failures []string
|
||||
for _, user := range users {
|
||||
// if sender is set, the conditions have already been evaluated
|
||||
if params.sender == "" {
|
||||
if !checkUserConditionOptions(&user, &conditions) {
|
||||
eventManagerLog(logger.LevelDebug, "skipping password check for user %q, condition options don't match",
|
||||
user.Username)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err = executePwdExpirationCheckForUser(&user, config); err != nil {
|
||||
params.AddError(err)
|
||||
failures = append(failures, user.Username)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("password expiration check failed for users: %+v", failures)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeRuleAction(action dataprovider.BaseEventAction, params *EventParams,
|
||||
conditions dataprovider.ConditionOptions,
|
||||
) error {
|
||||
@@ -1932,6 +1998,8 @@ func executeRuleAction(action dataprovider.BaseEventAction, params *EventParams,
|
||||
err = executeMetadataCheckRuleAction(conditions, params)
|
||||
case dataprovider.ActionTypeFilesystem:
|
||||
err = executeFsRuleAction(action.Options.FsConfig, conditions, params)
|
||||
case dataprovider.ActionTypePasswordExpirationCheck:
|
||||
err = executePwdExpirationCheckRuleAction(action.Options.PwdExpirationConfig, conditions, params)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported action type: %d", action.Type)
|
||||
}
|
||||
|
||||
@@ -408,9 +408,12 @@ func TestEventManagerErrors(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
err = executeCompressFsRuleAction(dataprovider.EventActionFsCompress{}, nil, dataprovider.ConditionOptions{}, &EventParams{})
|
||||
assert.Error(t, err)
|
||||
err = executePwdExpirationCheckRuleAction(dataprovider.EventActionPasswordExpiration{},
|
||||
dataprovider.ConditionOptions{}, &EventParams{})
|
||||
assert.Error(t, err)
|
||||
|
||||
groupName := "agroup"
|
||||
err = executeQuotaResetForUser(dataprovider.User{
|
||||
err = executeQuotaResetForUser(&dataprovider.User{
|
||||
Groups: []sdk.GroupMapping{
|
||||
{
|
||||
Name: groupName,
|
||||
@@ -419,7 +422,7 @@ func TestEventManagerErrors(t *testing.T) {
|
||||
},
|
||||
})
|
||||
assert.Error(t, err)
|
||||
err = executeMetadataCheckForUser(dataprovider.User{
|
||||
err = executeMetadataCheckForUser(&dataprovider.User{
|
||||
Groups: []sdk.GroupMapping{
|
||||
{
|
||||
Name: groupName,
|
||||
@@ -490,6 +493,14 @@ func TestEventManagerErrors(t *testing.T) {
|
||||
},
|
||||
}}, []string{"/a", "/b"}, nil)
|
||||
assert.Error(t, err)
|
||||
err = executePwdExpirationCheckForUser(&dataprovider.User{
|
||||
Groups: []sdk.GroupMapping{
|
||||
{
|
||||
Name: groupName,
|
||||
Type: sdk.GroupTypePrimary,
|
||||
},
|
||||
}}, dataprovider.EventActionPasswordExpiration{})
|
||||
assert.Error(t, err)
|
||||
|
||||
_, _, err = getHTTPRuleActionBody(dataprovider.EventActionHTTPConfig{
|
||||
Method: http.MethodPost,
|
||||
@@ -687,11 +698,24 @@ func TestEventRuleActions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
user2.Filters.PasswordExpiration = 10
|
||||
err = dataprovider.AddUser(&user1, "", "", "")
|
||||
assert.NoError(t, err)
|
||||
err = dataprovider.AddUser(&user2, "", "", "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = executePwdExpirationCheckRuleAction(dataprovider.EventActionPasswordExpiration{
|
||||
Threshold: 20,
|
||||
}, dataprovider.ConditionOptions{
|
||||
Names: []dataprovider.ConditionPattern{
|
||||
{
|
||||
Pattern: user2.Username,
|
||||
},
|
||||
},
|
||||
}, &EventParams{})
|
||||
// smtp not configured
|
||||
assert.Error(t, err)
|
||||
|
||||
action = dataprovider.BaseEventAction{
|
||||
Type: dataprovider.ActionTypeUserQuotaReset,
|
||||
}
|
||||
|
||||
@@ -5690,6 +5690,209 @@ func TestEventRuleIPBlocked(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestEventRulePasswordExpiration(t *testing.T) {
|
||||
smtpCfg := smtp.Config{
|
||||
Host: "127.0.0.1",
|
||||
Port: 2525,
|
||||
From: "notification@example.com",
|
||||
TemplatesPath: "templates",
|
||||
}
|
||||
err := smtpCfg.Initialize(configDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
|
||||
assert.NoError(t, err)
|
||||
a1 := dataprovider.BaseEventAction{
|
||||
Name: "a1",
|
||||
Type: dataprovider.ActionTypeEmail,
|
||||
Options: dataprovider.BaseEventActionOptions{
|
||||
EmailConfig: dataprovider.EventActionEmailConfig{
|
||||
Recipients: []string{"failure@example.net"},
|
||||
Subject: `Failure`,
|
||||
Body: "Failure action",
|
||||
},
|
||||
},
|
||||
}
|
||||
action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
|
||||
assert.NoError(t, err)
|
||||
a2 := dataprovider.BaseEventAction{
|
||||
Name: "a2",
|
||||
Type: dataprovider.ActionTypePasswordExpirationCheck,
|
||||
Options: dataprovider.BaseEventActionOptions{
|
||||
PwdExpirationConfig: dataprovider.EventActionPasswordExpiration{
|
||||
Threshold: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated)
|
||||
assert.NoError(t, err)
|
||||
a3 := dataprovider.BaseEventAction{
|
||||
Name: "a3",
|
||||
Type: dataprovider.ActionTypeEmail,
|
||||
Options: dataprovider.BaseEventActionOptions{
|
||||
EmailConfig: dataprovider.EventActionEmailConfig{
|
||||
Recipients: []string{"success@example.net"},
|
||||
Subject: `OK`,
|
||||
Body: "OK action",
|
||||
},
|
||||
},
|
||||
}
|
||||
action3, _, err := httpdtest.AddEventAction(a3, http.StatusCreated)
|
||||
assert.NoError(t, err)
|
||||
|
||||
r1 := dataprovider.EventRule{
|
||||
Name: "rule1",
|
||||
Trigger: dataprovider.EventTriggerFsEvent,
|
||||
Conditions: dataprovider.EventConditions{
|
||||
FsEvents: []string{"mkdir"},
|
||||
},
|
||||
Actions: []dataprovider.EventAction{
|
||||
{
|
||||
BaseEventAction: dataprovider.BaseEventAction{
|
||||
Name: action2.Name,
|
||||
},
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
BaseEventAction: dataprovider.BaseEventAction{
|
||||
Name: action3.Name,
|
||||
},
|
||||
Order: 2,
|
||||
},
|
||||
{
|
||||
BaseEventAction: dataprovider.BaseEventAction{
|
||||
Name: action1.Name,
|
||||
},
|
||||
Options: dataprovider.EventActionOptions{
|
||||
IsFailureAction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rule1, resp, err := httpdtest.AddEventRule(r1, http.StatusCreated)
|
||||
assert.NoError(t, err, string(resp))
|
||||
dirName := "aTestDir"
|
||||
|
||||
conn, client, err := getSftpClient(user)
|
||||
if assert.NoError(t, err) {
|
||||
defer conn.Close()
|
||||
defer client.Close()
|
||||
|
||||
lastReceivedEmail.reset()
|
||||
err := client.Mkdir(dirName)
|
||||
assert.NoError(t, err)
|
||||
// the user has no password expiration, the check will be skipped and the ok action executed
|
||||
assert.Eventually(t, func() bool {
|
||||
return lastReceivedEmail.get().From != ""
|
||||
}, 1500*time.Millisecond, 100*time.Millisecond)
|
||||
email := lastReceivedEmail.get()
|
||||
assert.Len(t, email.To, 1)
|
||||
assert.Contains(t, email.To, "success@example.net")
|
||||
err = client.RemoveDirectory(dirName)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
user.Filters.PasswordExpiration = 20
|
||||
_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
|
||||
assert.NoError(t, err)
|
||||
conn, client, err = getSftpClient(user)
|
||||
if assert.NoError(t, err) {
|
||||
defer conn.Close()
|
||||
defer client.Close()
|
||||
|
||||
lastReceivedEmail.reset()
|
||||
err := client.Mkdir(dirName)
|
||||
assert.NoError(t, err)
|
||||
// the passowrd is not about to expire, the check will be skipped and the ok action executed
|
||||
assert.Eventually(t, func() bool {
|
||||
return lastReceivedEmail.get().From != ""
|
||||
}, 1500*time.Millisecond, 100*time.Millisecond)
|
||||
email := lastReceivedEmail.get()
|
||||
assert.Len(t, email.To, 1)
|
||||
assert.Contains(t, email.To, "success@example.net")
|
||||
err = client.RemoveDirectory(dirName)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
user.Filters.PasswordExpiration = 5
|
||||
_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
|
||||
assert.NoError(t, err)
|
||||
conn, client, err = getSftpClient(user)
|
||||
if assert.NoError(t, err) {
|
||||
defer conn.Close()
|
||||
defer client.Close()
|
||||
|
||||
lastReceivedEmail.reset()
|
||||
err := client.Mkdir(dirName)
|
||||
assert.NoError(t, err)
|
||||
// the passowrd is about to expire, the user has no email, the failure action will be executed
|
||||
assert.Eventually(t, func() bool {
|
||||
return lastReceivedEmail.get().From != ""
|
||||
}, 1500*time.Millisecond, 100*time.Millisecond)
|
||||
email := lastReceivedEmail.get()
|
||||
assert.Len(t, email.To, 1)
|
||||
assert.Contains(t, email.To, "failure@example.net")
|
||||
err = client.RemoveDirectory(dirName)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
// remove the success action
|
||||
rule1.Actions = []dataprovider.EventAction{
|
||||
{
|
||||
BaseEventAction: dataprovider.BaseEventAction{
|
||||
Name: action2.Name,
|
||||
},
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
BaseEventAction: dataprovider.BaseEventAction{
|
||||
Name: action1.Name,
|
||||
},
|
||||
Options: dataprovider.EventActionOptions{
|
||||
IsFailureAction: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
_, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
user.Email = "user@example.net"
|
||||
_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
|
||||
assert.NoError(t, err)
|
||||
conn, client, err = getSftpClient(user)
|
||||
if assert.NoError(t, err) {
|
||||
defer conn.Close()
|
||||
defer client.Close()
|
||||
|
||||
lastReceivedEmail.reset()
|
||||
err := client.Mkdir(dirName)
|
||||
assert.NoError(t, err)
|
||||
// the passowrd expiration will be notified
|
||||
assert.Eventually(t, func() bool {
|
||||
return lastReceivedEmail.get().From != ""
|
||||
}, 1500*time.Millisecond, 100*time.Millisecond)
|
||||
email := lastReceivedEmail.get()
|
||||
assert.Len(t, email.To, 1)
|
||||
assert.Contains(t, email.To, user.Email)
|
||||
assert.Contains(t, email.Data, "Your SFTPGo password expires in 5 days")
|
||||
err = client.RemoveDirectory(dirName)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
_, err = httpdtest.RemoveEventAction(action2, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
_, err = httpdtest.RemoveEventAction(action3, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
_, err = httpdtest.RemoveUser(user, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
err = os.RemoveAll(user.GetHomeDir())
|
||||
assert.NoError(t, err)
|
||||
|
||||
smtpCfg = smtp.Config{}
|
||||
err = smtpCfg.Initialize(configDir)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSyncUploadAction(t *testing.T) {
|
||||
if runtime.GOOS == osWindows {
|
||||
t.Skip("this test is not available on Windows")
|
||||
|
||||
Reference in New Issue
Block a user