From 2da3eabc12e7ad7c3de329f6fdc151e7f7c87de1 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Fri, 16 Dec 2022 18:51:29 +0100 Subject: [PATCH] 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 --- docs/eventmanager.md | 1 + go.mod | 52 +++--- go.sum | 103 ++++++------ internal/common/eventmanager.go | 84 +++++++++- internal/common/eventmanager_test.go | 28 +++- internal/common/protocol_test.go | 203 +++++++++++++++++++++++ internal/dataprovider/eventrule.go | 50 +++++- internal/dataprovider/user.go | 13 ++ internal/httpd/httpd_test.go | 51 ++++++ internal/httpd/webadmin.go | 12 +- internal/httpdtest/httpdtest.go | 3 + internal/smtp/smtp.go | 19 ++- openapi/openapi.yaml | 8 + templates/email/password-expiration.html | 19 +++ templates/webadmin/eventaction.html | 24 +-- 15 files changed, 562 insertions(+), 108 deletions(-) create mode 100644 templates/email/password-expiration.html diff --git a/docs/eventmanager.md b/docs/eventmanager.md index 1ac971db..f6c3ebae 100644 --- a/docs/eventmanager.md +++ b/docs/eventmanager.md @@ -13,6 +13,7 @@ The following actions are supported: - `Transfer quota reset`. The transfer quota values will be reset to `0`. - `Data retention check`. You can define per-folder retention policies. - `Metadata check`. A metadata check requires a metadata plugin such as [this one](https://github.com/sftpgo/sftpgo-plugin-metadata) and removes the metadata associated to missing items (for example objects deleted outside SFTPGo). A metadata check does nothing is no metadata plugin is installed or external metadata are not supported for a filesystem. +- `Password expiration check`. You can send an email notification to users whose password is about to expire. - `Filesystem`. For these actions, the required permissions are automatically granted. This is the same as executing the actions from an SFTP client and the same restrictions applies. Supported actions: - `Rename`. You can rename one or more files or directories. - `Delete`. You can delete one or more files and directories. diff --git a/go.mod b/go.mod index 4dc4424e..4f3a94da 100644 --- a/go.mod +++ b/go.mod @@ -8,15 +8,15 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.6.1 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 - github.com/aws/aws-sdk-go-v2 v1.17.2 - github.com/aws/aws-sdk-go-v2/config v1.18.4 - github.com/aws/aws-sdk-go-v2/credentials v1.13.4 - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.20 - github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.43 - github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.25 - github.com/aws/aws-sdk-go-v2/service/s3 v1.29.5 - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.9 - github.com/aws/aws-sdk-go-v2/service/sts v1.17.6 + github.com/aws/aws-sdk-go-v2 v1.17.3 + github.com/aws/aws-sdk-go-v2/config v1.18.5 + github.com/aws/aws-sdk-go-v2/credentials v1.13.5 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.44 + github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.26 + github.com/aws/aws-sdk-go-v2/service/s3 v1.29.6 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.10 + github.com/aws/aws-sdk-go-v2/service/sts v1.17.7 github.com/cockroachdb/cockroach-go/v2 v2.2.19 github.com/coreos/go-oidc/v3 v3.4.0 github.com/drakkan/webdav v0.0.0-20221101181759-17ed21f9337b @@ -45,7 +45,7 @@ require ( github.com/otiai10/copy v1.9.0 github.com/pires/go-proxyproto v0.6.2 github.com/pkg/sftp v1.13.6-0.20221020054726-e4133ab7e9bd - github.com/pquerna/otp v1.3.0 + github.com/pquerna/otp v1.4.0 github.com/prometheus/client_golang v1.14.0 github.com/robfig/cron/v3 v3.0.1 github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5 @@ -72,28 +72,28 @@ require ( golang.org/x/sys v0.3.0 golang.org/x/term v0.3.0 golang.org/x/time v0.3.0 - google.golang.org/api v0.104.0 + google.golang.org/api v0.105.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 ) require ( cloud.google.com/go v0.107.0 // indirect cloud.google.com/go/compute v1.14.0 // indirect - cloud.google.com/go/compute/metadata v0.2.2 // indirect - cloud.google.com/go/iam v0.8.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.1 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect + cloud.google.com/go/iam v0.9.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 // indirect github.com/ajg/form v1.5.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.26 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.3.27 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.21 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.20 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.20 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.11.26 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.11.27 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.10 // indirect github.com/aws/smithy-go v1.13.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/boombuler/barcode v1.0.1 // indirect @@ -126,7 +126,7 @@ require ( github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.4 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/option v1.0.0 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect github.com/lib/pq v1.10.7 // indirect github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect github.com/magiconair/properties v1.8.7 // indirect @@ -141,9 +141,9 @@ require ( github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect + github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect github.com/prometheus/client_model v0.3.0 // indirect - github.com/prometheus/common v0.38.0 // indirect + github.com/prometheus/common v0.39.0 // indirect github.com/prometheus/procfs v0.8.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/cast v1.5.0 // indirect diff --git a/go.sum b/go.sum index 6d19df52..8a9cfdf9 100644 --- a/go.sum +++ b/go.sum @@ -52,16 +52,16 @@ cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLq cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0= cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo= -cloud.google.com/go/compute/metadata v0.2.2 h1:aWKAjYaBaOSrpKl57+jnS/3fJRQnxL7TvR/u1VVbt6k= -cloud.google.com/go/compute/metadata v0.2.2/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c= cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= -cloud.google.com/go/iam v0.8.0 h1:E2osAkZzxI/+8pZcxVLcDtAQx/u+hZXVryUaYQ5O0Kk= -cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE= +cloud.google.com/go/iam v0.9.0 h1:bK6Or6mxhuL8lnj1i9j0yMo2wE/IeTO2cWlfUrf/TZs= +cloud.google.com/go/iam v0.9.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM= cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA= cloud.google.com/go/kms v1.6.0 h1:OWRZzrPmOZUzurjI2FBGtgY2mB1WaJkqhw6oIwSj0Yg= cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs= @@ -108,8 +108,8 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0/go.mod h1:+6sju8gk8FRmSa github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0 h1:QkAcEIAKbNL4KoFr4SathZPhDhF4mVwpBMFlYjyAqy8= github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.1 h1:Oj853U9kG+RLTCQXpjvOnrv0WaZHxgmZz1TlLywgOPY= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.1/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2 h1:+5VZ72z0Qan5Bog5C+ZkgSqUbeVUd9wgtHOrIKuc5b8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.0.2/go.mod h1:LH9XQnMr2ZYxQdVdCrzLO9mxeDyrDFa6wbSI3x5zCZk= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.4.1/go.mod h1:eZ4g6GUvXiGulfIbbhh1Xr4XwUYaYaWMqzGD/284wCA= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.6.1 h1:YvQv9Mz6T8oR5ypQOL6erY0Z5t71ak1uHV4QFokCOZk= @@ -227,67 +227,67 @@ github.com/aws/aws-sdk-go v1.44.45/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4 github.com/aws/aws-sdk-go v1.44.68/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/aws/aws-sdk-go-v2 v1.16.8/go.mod h1:6CpKuLXg2w7If3ABZCl/qZ6rEgwtjZTn4eAf4RcEyuw= -github.com/aws/aws-sdk-go-v2 v1.17.2 h1:r0yRZInwiPBNpQ4aDy/Ssh3ROWsGtKDwar2JS8Lm+N8= -github.com/aws/aws-sdk-go-v2 v1.17.2/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= +github.com/aws/aws-sdk-go-v2 v1.17.3 h1:shN7NlnVzvDUgPQ+1rLMSxY8OWRNDRYtiqe0p/PgrhY= +github.com/aws/aws-sdk-go-v2 v1.17.3/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3/go.mod h1:gNsR5CaXKmQSSzrmGxmwmct/r+ZBfbxorAuXYsj/M5Y= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno= github.com/aws/aws-sdk-go-v2/config v1.15.15/go.mod h1:A1Lzyy/o21I5/s2FbyX5AevQfSVXpvvIDCoVFD0BC4E= -github.com/aws/aws-sdk-go-v2/config v1.18.4 h1:VZKhr3uAADXHStS/Gf9xSYVmmaluTUfkc0dcbPiDsKE= -github.com/aws/aws-sdk-go-v2/config v1.18.4/go.mod h1:EZxMPLSdGAZ3eAmkqXfYbRppZJTzFTkv8VyEzJhKko4= +github.com/aws/aws-sdk-go-v2/config v1.18.5 h1:teGdDCAT3gX99FIKNt6HsvLaeOVdCFiCQDlH8UV6Xvg= +github.com/aws/aws-sdk-go-v2/config v1.18.5/go.mod h1:0g4tGVHeUTxekZIkO5Glw2AemETlmnkQvFqkdv3HBAA= github.com/aws/aws-sdk-go-v2/credentials v1.12.10/go.mod h1:g5eIM5XRs/OzIIK81QMBl+dAuDyoLN0VYaLP+tBqEOk= -github.com/aws/aws-sdk-go-v2/credentials v1.13.4 h1:nEbHIyJy7mCvQ/kzGG7VWHSBpRB4H6sJy3bWierWUtg= -github.com/aws/aws-sdk-go-v2/credentials v1.13.4/go.mod h1:/Cj5w9LRsNTLSwexsohwDME32OzJ6U81Zs33zr2ZWOM= +github.com/aws/aws-sdk-go-v2/credentials v1.13.5 h1:vrPwnKCdQlUyxXDZtPpb6Hc3GbTndqaGtEOwm/lF5tI= +github.com/aws/aws-sdk-go-v2/credentials v1.13.5/go.mod h1:sS/NgdbdkQ6XhVkGY/yEmNwxzpRVxLT3Ns+42W37p6g= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.9/go.mod h1:KDCCm4ONIdHtUloDcFvK2+vshZvx4Zmj7UMDfusuz5s= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.20 h1:tpNOglTZ8kg9T38NpcGBxudqfUAwUzyUnLQ4XSd0CHE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.20/go.mod h1:d9xFpWd3qYwdIXM0fvu7deD08vvdRXyc/ueV+0SqaWE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21 h1:j9wi1kQ8b+e0FBVHxCqCGo4kxDU175hoDHcWAi0sauU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21/go.mod h1:ugwW57Z5Z48bpvUyZuaPy4Kv+vEfJWnIrky7RmkBvJg= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.21/go.mod h1:iIYPrQ2rYfZiB/iADYlhj9HHZ9TTi6PqKQPAqygohbE= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.43 h1:+bkAMTd5OGyHu2nwNOangjEsP65fR0uhMbZJA52sZ64= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.43/go.mod h1:sS2tu0VEspKuY5eM1vQgy7P/hpZX8F62o6qsghZExWc= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.44 h1:BXBHQeRh4fGFiiMZZd/WI/aAPwjhUCrWDhRDbuOZPIM= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.44/go.mod h1:yFjq/63PmWzfzHk66kMsouDqS/ilrjFKWu269GWE6iw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.15/go.mod h1:pWrr2OoHlT7M/Pd2y4HV3gJyPb3qj5qMmnPkKSNPYK4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.26 h1:5WU31cY7m0tG+AiaXuXGoMzo2GBQ1IixtWa8Yywsgco= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.26/go.mod h1:2E0LdbJW6lbeU4uxjum99GZzI0ZjDpAb0CoSCM0oeEY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27 h1:I3cakv2Uy1vNmmhRQmFptYDxOvBnwCdNwyw63N0RaRU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27/go.mod h1:a1/UpzeyBBerajpnP5nGZa9mGzsBn5cOKxm6NWQsvoI= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.9/go.mod h1:08tUpeSGN33QKSO7fwxXczNfiwCpbj+GxK6XKwqWVv0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.20 h1:WW0qSzDWoiWU2FS5DbKpxGilFVlCEJPwx4YtjdfI0Jw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.20/go.mod h1:/+6lSiby8TBFpTVXZgKiN/rCfkYXEGvhlM4zCgPpt7w= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21 h1:5NbbMrIzmUn/TXFqAle6mgrH5m9cOvMLRGL7pnG8tRE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21/go.mod h1:+Gxn8jYn5k9ebfHEqlhrMirFjSW0v0C9fI+KN5vk2kE= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.16/go.mod h1:CYmI+7x03jjJih8kBEEFKRQc40UjUokT0k7GbvrhhTc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.27 h1:N2eKFw2S+JWRCtTt0IhIX7uoGGQciD4p6ba+SJv4WEU= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.27/go.mod h1:RdwFVc7PBYWY33fa2+8T1mSqQ7ZEK4ILpM0wfioDC3w= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28 h1:KeTxcGdNnQudb46oOl4d90f2I33DF/c6q3RnZAmvQdQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28/go.mod h1:yRZVr/iT0AqyHeep00SZ4YfBAKojXz08w3XMBscdi0c= github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.6/go.mod h1:O7Oc4peGZDEKlddivslfYFvAbgzvl/GH3J8j3JIGBXc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.17 h1:5tXbMJ7Jq0iG65oiMg6tCLsHkSaO2xLXa2EmZ29vaTA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.17/go.mod h1:twV0fKMQuqLY4klyFH56aXNq3AFiA5LO0/frTczEOFE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18 h1:H/mF2LNWwX00lD6FlYfKpLLZgUW7oIzCBkig78x4Xok= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18/go.mod h1:T2Ku+STrYQ1zIkL1wMvj8P3wWQaaCMKNdz70MT2FLfE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3/go.mod h1:gkb2qADY+OHaGLKNTYxMaQNacfeyQpZ4csDTQMeFmcw= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.10/go.mod h1:Qks+dxK3O+Z2deAhNo6cJ8ls1bam3tUGUAcgxQP1c70= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.21 h1:77b1GfaSuIok5yB/3HYbG+ypWvOJDQ2rVdq943D17R4= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.21/go.mod h1:sPOz31BVdqeeurKEuUpLNSve4tdCNPluE+070HNcEHI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22 h1:kv5vRAl00tozRxSnI0IszPWGXsJOyA7hmEUHFYqsyvw= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22/go.mod h1:Od+GU5+Yx41gryN/ZGZzAJMZ9R1yn6lgA0fD5Lo5SkQ= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.9/go.mod h1:yQowTpvdZkFVuHrLBXmczat4W+WJKg/PafBZnGBLga0= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.20 h1:jlgyHbkZQAgAc7VIxJDmtouH8eNjOk2REVAQfVhdaiQ= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.20/go.mod h1:Xs52xaLBqDEKRcAfX/hgjmD3YQ7c/W+BEyfamlO/W2E= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21 h1:5C6XgTViSb0bunmU57b3CT+MhxULqHH2721FVA+/kDM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21/go.mod h1:lRToEJsn+DRA9lW4O9L9+/3hjTkUzlzyzHqn8MTds5k= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.9/go.mod h1:Rc5+wn2k8gFSi3V1Ch4mhxOzjMh+bYSXVFfVaqowQOY= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.20 h1:4K6dbmR0mlp3o4Bo78PnpvzHtYAqEeVMguvEenpMGsI= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.20/go.mod h1:1XpDcReIEOHsjwNToDKhIAO3qwLo1BnfbtSqWJa8j7g= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21 h1:vY5siRXvW5TrOKm2qKEf9tliBfdLxdfy0i02LOcmqUo= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21/go.mod h1:WZvNXT1XuH8dnJM0HvOlvk+RNn7NbAPvA/ACO0QarSc= github.com/aws/aws-sdk-go-v2/service/kms v1.18.1/go.mod h1:4PZMUkc9rXHWGVB5J9vKaZy3D7Nai79ORworQ3ASMiM= -github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.25 h1:rCfUDMT9KO++W4TkIJr3BO0h749xkRX4RpHdydSFfHw= -github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.25/go.mod h1:xCXhZdPR0p6uaNhggLVGREnJaUIc2CNOJydWnZ2R7OQ= +github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.26 h1:NlA5om7Um+ohI/S0inBd55Vsp84BKhBrAgw2IVqEb30= +github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.13.26/go.mod h1:DSuypbY6jb7WZSxrLuCgd7ouB5uRQ+Hg5wbt0GmgRcc= github.com/aws/aws-sdk-go-v2/service/s3 v1.27.2/go.mod h1:u+566cosFI+d+motIz3USXEh6sN8Nq4GrNXSg2RXVMo= -github.com/aws/aws-sdk-go-v2/service/s3 v1.29.5 h1:nRSEQj1JergKTVc8RGkhZvOEGgcvo4fWpDPwGDeg2ok= -github.com/aws/aws-sdk-go-v2/service/s3 v1.29.5/go.mod h1:wcaJTmjKFDW0s+Se55HBNIds6ghdAGoDDw+SGUdrfAk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.29.6 h1:W8pLcSn6Uy0eXgDBUUl8M8Kxv7JCoP68ZKTD04OXLEA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.29.6/go.mod h1:L2l2/q76teehcW7YEsgsDjqdsDTERJeX3nOMIFlgGUE= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.14/go.mod h1:xakbH8KMsQQKqzX87uyyzTHshc/0/Df8bsTneTS5pFU= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.9 h1:ogcakjF/mrZOo9oJVWmRbG838C04oWGXI8T8IY4xcfM= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.9/go.mod h1:S7AsUoaHONHV2iGM5QXQOonnaV05cK9fty2dXRdouws= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.10 h1:6obimjQAiRlEUZT7a2Q1ikH7ck4cPO3phGz4wqI5f2w= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.16.10/go.mod h1:jAeo/PdIJZuDSwsvxJS94G4d6h8tStj7WXVuKwLHWU8= github.com/aws/aws-sdk-go-v2/service/sns v1.17.10/go.mod h1:uITsRNVMeCB3MkWpXxXw0eDz8pW4TYLzj+eyQtbhSxM= github.com/aws/aws-sdk-go-v2/service/sqs v1.19.1/go.mod h1:A94o564Gj+Yn+7QO1eLFeI7UVv3riy/YBFOfICVqFvU= github.com/aws/aws-sdk-go-v2/service/ssm v1.27.6/go.mod h1:fiFzQgj4xNOg4/wqmAiPvzgDMXPD+cUEplX/CYn+0j0= github.com/aws/aws-sdk-go-v2/service/sso v1.11.13/go.mod h1:d7ptRksDDgvXaUvxyHZ9SYh+iMDymm94JbVcgvSYSzU= -github.com/aws/aws-sdk-go-v2/service/sso v1.11.26 h1:ActQgdTNQej/RuUJjB9uxYVLDOvRGtUreXF8L3c8wyg= -github.com/aws/aws-sdk-go-v2/service/sso v1.11.26/go.mod h1:uB9tV79ULEZUXc6Ob18A46KSQ0JDlrplPni9XW6Ot60= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.9 h1:wihKuqYUlA2T/Rx+yu2s6NDAns8B9DgnRooB1PVhY+Q= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.9/go.mod h1:2E/3D/mB8/r2J7nK42daoKP/ooCwbf0q1PznNc+DZTU= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.27 h1:Nmvn0DJKg00TBmoBweK253Kdsuy4V5Rs68yL/H15uBQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.27/go.mod h1:wo/B7uUm/7zw/dWhBJ4FXuw1sySU5lyIhVg1Bu2yL9A= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.10 h1:tGOUUjINuqI8sD6pn+Ku0/f/4UfRDlK+jJUOaxEbWuQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.10/go.mod h1:TZSH7xLO7+phDtViY/KUp9WGCJMQkLJ/VpgkTFd5gh8= github.com/aws/aws-sdk-go-v2/service/sts v1.16.10/go.mod h1:cftkHYN6tCDNfkSasAmclSfl4l7cySoay8vz7p/ce0E= -github.com/aws/aws-sdk-go-v2/service/sts v1.17.6 h1:VQFOLQVL3BrKM/NLO/7FiS4vcp5bqK0mGMyk09xLoAY= -github.com/aws/aws-sdk-go-v2/service/sts v1.17.6/go.mod h1:Az3OXXYGyfNwQNsK/31L4R75qFYnO641RZGAoV3uH1c= +github.com/aws/aws-sdk-go-v2/service/sts v1.17.7 h1:9Mtq1KM6nD8/+HStvWcvYnixJ5N85DX+P+OY3kI3W2k= +github.com/aws/aws-sdk-go-v2/service/sts v1.17.7/go.mod h1:+lGbb3+1ugwKrNTWcf2RT05Xmp543B06zDFTwiTLp7I= github.com/aws/smithy-go v1.12.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= @@ -1099,8 +1099,9 @@ github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzlt github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= github.com/lestrrat-go/jwx/v2 v2.0.8 h1:jCFT8oc0hEDVjgUgsBy1F9cbjsjAVZSXNi7JaU9HR/Q= github.com/lestrrat-go/jwx/v2 v2.0.8/go.mod h1:zLxnyv9rTlEvOUHbc48FAfIL8iYu2hHvIRaTFGc8mT0= -github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4= github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -1344,11 +1345,11 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c h1:NRoLoZvkBTKvR5gQLgA3e0hqjkY9u1wm+iOL45VN/qI= -github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b h1:0LFwY6Q3gMACTjAbMZBjXAqTOzOwFaj2Ld6cjeQ7Rig= +github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= -github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs= -github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/alertmanager v0.24.0/go.mod h1:r6fy/D7FRuZh5YbnX6J3MBY0eI4Pb5yPYS7/bPSXXqI= @@ -1390,8 +1391,8 @@ github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+ github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= github.com/prometheus/common v0.34.0/go.mod h1:gB3sOl7P0TvJabZpLY5uQMpUqRCPPCyRLCZYc7JZTNE= github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= -github.com/prometheus/common v0.38.0 h1:VTQitp6mXTdUoCmDMugDVOJ1opi6ADftKfp/yeqTR/E= -github.com/prometheus/common v0.38.0/go.mod h1:MBXfmBQZrK5XpbCkjofnXs96LD2QQ7fEq4C0xjC/yec= +github.com/prometheus/common v0.39.0 h1:oOyhkDq05hPZKItWVBkJ6g6AtGxi+fy7F4JvUV8uhsI= +github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NXd5w0BbEX0Y= github.com/prometheus/common/assets v0.1.0/go.mod h1:D17UVUE12bHbim7HzwUvtqm6gwBEaDQ0F+hIGbFbccI= github.com/prometheus/common/assets v0.2.0/go.mod h1:D17UVUE12bHbim7HzwUvtqm6gwBEaDQ0F+hIGbFbccI= github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= @@ -2189,8 +2190,8 @@ google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6F google.golang.org/api v0.86.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= google.golang.org/api v0.91.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= -google.golang.org/api v0.104.0 h1:KBfmLRqdZEbwQleFlSLnzpQJwhjpmNOk4cKQIBDZ9mg= -google.golang.org/api v0.104.0/go.mod h1:JCspTXJbBxa5ySXw4UgUqVer7DfVxbvc/CTUFqAED5U= +google.golang.org/api v0.105.0 h1:t6P9Jj+6XTn4U9I2wycQai6Q/Kz7iOT+QzjJ3G2V4x8= +google.golang.org/api v0.105.0/go.mod h1:qh7eD5FJks5+BcE+cjBIm6Gz8vioK7EHvnlniqXBnqI= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go index d35d48f1..5d0bef0f 100644 --- a/internal/common/eventmanager.go +++ b/internal/common/eventmanager.go @@ -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) } diff --git a/internal/common/eventmanager_test.go b/internal/common/eventmanager_test.go index 4c314a24..78c38aa6 100644 --- a/internal/common/eventmanager_test.go +++ b/internal/common/eventmanager_test.go @@ -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, } diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index d98b0988..d015a729 100644 --- a/internal/common/protocol_test.go +++ b/internal/common/protocol_test.go @@ -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") diff --git a/internal/dataprovider/eventrule.go b/internal/dataprovider/eventrule.go index 0b8370bd..04f19f5c 100644 --- a/internal/dataprovider/eventrule.go +++ b/internal/dataprovider/eventrule.go @@ -45,12 +45,13 @@ const ( ActionTypeDataRetentionCheck ActionTypeFilesystem ActionTypeMetadataCheck + ActionTypePasswordExpirationCheck ) var ( supportedEventActions = []int{ActionTypeHTTP, ActionTypeCommand, ActionTypeEmail, ActionTypeFilesystem, ActionTypeBackup, ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset, - ActionTypeDataRetentionCheck, ActionTypeMetadataCheck} + ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypePasswordExpirationCheck} ) func isActionTypeValid(action int) bool { @@ -77,6 +78,8 @@ func getActionTypeAsString(action int) string { return "Metadata check" case ActionTypeFilesystem: return "Filesystem" + case ActionTypePasswordExpirationCheck: + return "Password expiration check" default: return "Command" } @@ -755,13 +758,28 @@ func (c *EventActionFilesystemConfig) getACopy() EventActionFilesystemConfig { } } +// EventActionPasswordExpiration defines the configuration for password expiration actions +type EventActionPasswordExpiration struct { + // An email notification will be generated for users whose password expires in a number + // of days less than or equal to this threshold + Threshold int `json:"threshold,omitempty"` +} + +func (c *EventActionPasswordExpiration) validate() error { + if c.Threshold <= 0 { + return util.NewValidationError("threshold must be greater than 0") + } + return nil +} + // BaseEventActionOptions defines the supported configuration options for a base event actions type BaseEventActionOptions struct { - HTTPConfig EventActionHTTPConfig `json:"http_config"` - CmdConfig EventActionCommandConfig `json:"cmd_config"` - EmailConfig EventActionEmailConfig `json:"email_config"` - RetentionConfig EventActionDataRetentionConfig `json:"retention_config"` - FsConfig EventActionFilesystemConfig `json:"fs_config"` + HTTPConfig EventActionHTTPConfig `json:"http_config"` + CmdConfig EventActionCommandConfig `json:"cmd_config"` + EmailConfig EventActionEmailConfig `json:"email_config"` + RetentionConfig EventActionDataRetentionConfig `json:"retention_config"` + FsConfig EventActionFilesystemConfig `json:"fs_config"` + PwdExpirationConfig EventActionPasswordExpiration `json:"pwd_expiration_config"` } func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions { @@ -819,6 +837,9 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions { RetentionConfig: EventActionDataRetentionConfig{ Folders: folders, }, + PwdExpirationConfig: EventActionPasswordExpiration{ + Threshold: o.PwdExpirationConfig.Threshold, + }, FsConfig: o.FsConfig.getACopy(), } } @@ -850,37 +871,50 @@ func (o *BaseEventActionOptions) validate(action int, name string) error { o.EmailConfig = EventActionEmailConfig{} o.RetentionConfig = EventActionDataRetentionConfig{} o.FsConfig = EventActionFilesystemConfig{} + o.PwdExpirationConfig = EventActionPasswordExpiration{} return o.HTTPConfig.validate(name) case ActionTypeCommand: o.HTTPConfig = EventActionHTTPConfig{} o.EmailConfig = EventActionEmailConfig{} o.RetentionConfig = EventActionDataRetentionConfig{} o.FsConfig = EventActionFilesystemConfig{} + o.PwdExpirationConfig = EventActionPasswordExpiration{} return o.CmdConfig.validate() case ActionTypeEmail: o.HTTPConfig = EventActionHTTPConfig{} o.CmdConfig = EventActionCommandConfig{} o.RetentionConfig = EventActionDataRetentionConfig{} o.FsConfig = EventActionFilesystemConfig{} + o.PwdExpirationConfig = EventActionPasswordExpiration{} return o.EmailConfig.validate() case ActionTypeDataRetentionCheck: o.HTTPConfig = EventActionHTTPConfig{} o.CmdConfig = EventActionCommandConfig{} o.EmailConfig = EventActionEmailConfig{} o.FsConfig = EventActionFilesystemConfig{} + o.PwdExpirationConfig = EventActionPasswordExpiration{} return o.RetentionConfig.validate() case ActionTypeFilesystem: o.HTTPConfig = EventActionHTTPConfig{} o.CmdConfig = EventActionCommandConfig{} o.EmailConfig = EventActionEmailConfig{} o.RetentionConfig = EventActionDataRetentionConfig{} + o.PwdExpirationConfig = EventActionPasswordExpiration{} return o.FsConfig.validate() + case ActionTypePasswordExpirationCheck: + o.HTTPConfig = EventActionHTTPConfig{} + o.CmdConfig = EventActionCommandConfig{} + o.EmailConfig = EventActionEmailConfig{} + o.RetentionConfig = EventActionDataRetentionConfig{} + o.FsConfig = EventActionFilesystemConfig{} + return o.PwdExpirationConfig.validate() default: o.HTTPConfig = EventActionHTTPConfig{} o.CmdConfig = EventActionCommandConfig{} o.EmailConfig = EventActionEmailConfig{} o.RetentionConfig = EventActionDataRetentionConfig{} o.FsConfig = EventActionFilesystemConfig{} + o.PwdExpirationConfig = EventActionPasswordExpiration{} } return nil } @@ -1322,7 +1356,7 @@ func (r *EventRule) validate() error { func (r *EventRule) checkIPBlockedAndCertificateActions() error { unavailableActions := []int{ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset, - ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypeFilesystem} + ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypeFilesystem, ActionTypePasswordExpirationCheck} for _, action := range r.Actions { if util.Contains(unavailableActions, action.Type) { return fmt.Errorf("action %q, type %q is not supported for event trigger %q", @@ -1337,7 +1371,7 @@ func (r *EventRule) checkProviderEventActions(providerObjectType string) error { // can be executed only if we modify a user. They will be executed for the // affected user. Folder quota reset can be executed only for folders. userSpecificActions := []int{ActionTypeUserQuotaReset, ActionTypeTransferQuotaReset, - ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypeFilesystem} + ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypeFilesystem, ActionTypePasswordExpirationCheck} for _, action := range r.Actions { if util.Contains(userSpecificActions, action.Type) && providerObjectType != actionObjectUser { return fmt.Errorf("action %q, type %q is only supported for provider user events", diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go index ca87e187..afa5627f 100644 --- a/internal/dataprovider/user.go +++ b/internal/dataprovider/user.go @@ -1110,6 +1110,19 @@ func (u *User) CanDeleteFromWeb(target string) bool { return u.HasAnyPerm(permsDeleteAny, target) } +// PasswordExpiresIn returns the number of days before the password expires. +// The returned value is negative if the password is expired. +// The caller must ensure that a PasswordExpiration is set +func (u *User) PasswordExpiresIn() int { + lastPwdChange := util.GetTimeFromMsecSinceEpoch(u.LastPasswordChange) + pwdExpiration := lastPwdChange.Add(time.Duration(u.Filters.PasswordExpiration) * 24 * time.Hour) + res := int(math.Round(float64(time.Until(pwdExpiration)) / float64(24*time.Hour))) + if res == 0 && pwdExpiration.After(time.Now()) { + res = 1 + } + return res +} + // MustChangePassword returns true if the user must change the password func (u *User) MustChangePassword() bool { if u.Filters.RequirePasswordChange { diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 173ef1f8..94b3516e 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -1903,6 +1903,11 @@ func TestEventActionValidation(t *testing.T) { _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) assert.NoError(t, err) assert.Contains(t, string(resp), "invalid path to compress") + action.Type = dataprovider.ActionTypePasswordExpirationCheck + action.Options.PwdExpirationConfig.Threshold = 0 + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "threshold must be greater than 0") } func TestEventRuleValidation(t *testing.T) { @@ -20011,6 +20016,7 @@ func TestWebEventAction(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), "invalid http timeout") form.Set("cmd_timeout", "20") + form.Set("pwd_expiration_threshold", "10") form.Set("http_timeout", fmt.Sprintf("%d", action.Options.HTTPConfig.Timeout)) form.Set("http_header_key0", action.Options.HTTPConfig.Headers[0].Key) form.Set("http_header_val0", action.Options.HTTPConfig.Headers[0].Value) @@ -20205,6 +20211,7 @@ func TestWebEventAction(t *testing.T) { assert.Equal(t, action.Options.CmdConfig.Timeout, actionGet.Options.CmdConfig.Timeout) assert.Equal(t, action.Options.CmdConfig.EnvVars, actionGet.Options.CmdConfig.EnvVars) assert.Equal(t, dataprovider.EventActionHTTPConfig{}, actionGet.Options.HTTPConfig) + assert.Equal(t, dataprovider.EventActionPasswordExpiration{}, actionGet.Options.PwdExpirationConfig) // change action type again action.Type = dataprovider.ActionTypeEmail action.Options.EmailConfig = dataprovider.EventActionEmailConfig{ @@ -20352,6 +20359,33 @@ func TestWebEventAction(t *testing.T) { } } + action.Type = dataprovider.ActionTypePasswordExpirationCheck + action.Options.PwdExpirationConfig.Threshold = 15 + form.Set("type", fmt.Sprintf("%d", action.Type)) + form.Set("pwd_expiration_threshold", "a") + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid password expiration threshold") + form.Set("pwd_expiration_threshold", strconv.Itoa(action.Options.PwdExpirationConfig.Threshold)) + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + actionGet, _, err = httpdtest.GetEventActionByName(action.Name, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, action.Type, actionGet.Type) + assert.Equal(t, action.Options.PwdExpirationConfig.Threshold, actionGet.Options.PwdExpirationConfig.Threshold) + assert.Equal(t, 0, actionGet.Options.CmdConfig.Timeout) + assert.Len(t, actionGet.Options.CmdConfig.EnvVars, 0) + req, err = http.NewRequest(http.MethodDelete, path.Join(webAdminEventActionPath, action.Name), nil) assert.NoError(t, err) setBearerForReq(req, apiToken) @@ -22212,6 +22246,23 @@ func TestPasswordChangeRequired(t *testing.T) { assert.True(t, user.MustChangePassword()) } +func TestPasswordExpiresIn(t *testing.T) { + user := getTestUser() + user.Filters.PasswordExpiration = 30 + user.LastPasswordChange = util.GetTimeAsMsSinceEpoch(time.Now().Add(-15*24*time.Hour + 1*time.Hour)) + res := user.PasswordExpiresIn() + assert.Equal(t, 15, res) + user.Filters.PasswordExpiration = 15 + res = user.PasswordExpiresIn() + assert.Equal(t, 1, res) + user.LastPasswordChange = util.GetTimeAsMsSinceEpoch(time.Now().Add(-15*24*time.Hour - 1*time.Hour)) + res = user.PasswordExpiresIn() + assert.Equal(t, 0, res) + user.Filters.PasswordExpiration = 5 + res = user.PasswordExpiresIn() + assert.Equal(t, -10, res) +} + func TestSecondFactorRequirements(t *testing.T) { user := getTestUser() user.Filters.TwoFactorAuthProtocols = []string{common.ProtocolHTTP, common.ProtocolSSH} diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index da25104f..933096db 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -1012,7 +1012,7 @@ func (s *httpdServer) renderEventActionPage(w http.ResponseWriter, r *http.Reque currentURL = webAdminEventActionPath case genericPageModeUpdate: title = "Update event action" - currentURL = fmt.Sprintf("%v/%v", webAdminEventActionPath, url.PathEscape(action.Name)) + currentURL = fmt.Sprintf("%s/%s", webAdminEventActionPath, url.PathEscape(action.Name)) } if action.Options.HTTPConfig.Timeout == 0 { action.Options.HTTPConfig.Timeout = 20 @@ -1020,6 +1020,9 @@ func (s *httpdServer) renderEventActionPage(w http.ResponseWriter, r *http.Reque if action.Options.CmdConfig.Timeout == 0 { action.Options.CmdConfig.Timeout = 20 } + if action.Options.PwdExpirationConfig.Threshold == 0 { + action.Options.PwdExpirationConfig.Threshold = 10 + } data := eventActionPage{ basePage: s.getBasePageData(title, currentURL, r), @@ -2122,6 +2125,10 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven if err != nil { return dataprovider.BaseEventActionOptions{}, fmt.Errorf("invalid fs action type: %w", err) } + pwdExpirationThreshold, err := strconv.Atoi(r.Form.Get("pwd_expiration_threshold")) + if err != nil { + return dataprovider.BaseEventActionOptions{}, fmt.Errorf("invalid password expiration threshold: %w", err) + } var emailAttachments []string if r.Form.Get("email_attachments") != "" { emailAttachments = strings.Split(strings.ReplaceAll(r.Form.Get("email_attachments"), " ", ""), ",") @@ -2169,6 +2176,9 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven Paths: strings.Split(strings.ReplaceAll(r.Form.Get("fs_compress_paths"), " ", ""), ","), }, }, + PwdExpirationConfig: dataprovider.EventActionPasswordExpiration{ + Threshold: pwdExpirationThreshold, + }, } return options, nil } diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go index 5a811c0d..a14196d1 100644 --- a/internal/httpdtest/httpdtest.go +++ b/internal/httpdtest/httpdtest.go @@ -1450,6 +1450,9 @@ func checkEventAction(expected, actual dataprovider.BaseEventAction) error { if expected.Type != actual.Type { return errors.New("type mismatch") } + if expected.Options.PwdExpirationConfig.Threshold != actual.Options.PwdExpirationConfig.Threshold { + return errors.New("password expiration threshold mismatch") + } if err := compareEventActionCmdConfigFields(expected.Options.CmdConfig, actual.Options.CmdConfig); err != nil { return err } diff --git a/internal/smtp/smtp.go b/internal/smtp/smtp.go index 61bef9f5..37926753 100644 --- a/internal/smtp/smtp.go +++ b/internal/smtp/smtp.go @@ -43,8 +43,9 @@ const ( ) const ( - templateEmailDir = "email" - templatePasswordReset = "reset-password.html" + templateEmailDir = "email" + templatePasswordReset = "reset-password.html" + templatePasswordExpiration = "password-expiration.html" ) var ( @@ -158,8 +159,11 @@ func loadTemplates(templatesPath string) { passwordResetPath := filepath.Join(templatesPath, templatePasswordReset) pwdResetTmpl := util.LoadTemplate(nil, passwordResetPath) + passwordExpirationPath := filepath.Join(templatesPath, templatePasswordExpiration) + pwdExpirationTmpl := util.LoadTemplate(nil, passwordExpirationPath) emailTemplates[templatePasswordReset] = pwdResetTmpl + emailTemplates[templatePasswordExpiration] = pwdExpirationTmpl } // RenderPasswordResetTemplate executes the password reset template @@ -170,11 +174,22 @@ func RenderPasswordResetTemplate(buf *bytes.Buffer, data any) error { return emailTemplates[templatePasswordReset].Execute(buf, data) } +// RenderPasswordExpirationTemplate executes the password expiration template +func RenderPasswordExpirationTemplate(buf *bytes.Buffer, data any) error { + if smtpServer == nil { + return errors.New("smtp: not configured") + } + return emailTemplates[templatePasswordExpiration].Execute(buf, data) +} + // SendEmail tries to send an email using the specified parameters. func SendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...mail.File) error { if smtpServer == nil { return errors.New("smtp: not configured") } + if len(to) == 0 { + return errors.New("smtp: cannot send an email without recipients") + } smtpClient, err := smtpServer.Connect() if err != nil { return fmt.Errorf("smtp: unable to connect: %w", err) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index fb116223..0984c186 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -6463,6 +6463,12 @@ components: type: string compress: $ref: '#/components/schemas/EventActionFsCompress' + EventActionPasswordExpiration: + type: object + properties: + threshold: + type: integer + description: 'An email notification will be generated for users whose password expires in a number of days less than or equal to this threshold' BaseEventActionOptions: type: object properties: @@ -6476,6 +6482,8 @@ components: $ref: '#/components/schemas/EventActionDataRetentionConfig' fs_config: $ref: '#/components/schemas/EventActionFilesystemConfig' + pwd_expiration_config: + $ref: '#/components/schemas/EventActionPasswordExpiration' BaseEventAction: type: object properties: diff --git a/templates/email/password-expiration.html b/templates/email/password-expiration.html new file mode 100644 index 00000000..08a1d4f9 --- /dev/null +++ b/templates/email/password-expiration.html @@ -0,0 +1,19 @@ + +Hi {{.Username}}, +
+

Your SFTPGo password {{if le .Days 0}}has expired{{else}}expires in {{.Days}} {{if eq .Days 1}}day{{else}}days{{end}}{{end}}.

+

Please login to the WebClient and set a new password.

\ No newline at end of file diff --git a/templates/webadmin/eventaction.html b/templates/webadmin/eventaction.html index 7aa36453..1ca9bf0b 100644 --- a/templates/webadmin/eventaction.html +++ b/templates/webadmin/eventaction.html @@ -74,6 +74,17 @@ along with this program. If not, see . +
+ +
+ + + An email notification will be generated for users whose password expires in a number of days less than or equal to this threshold. + +
+
+
@@ -916,26 +927,24 @@ along with this program. If not, see . $('.action-type').hide(); switch (val) { case '1': - case 1: $('.action-http').show(); break; case '2': - case 2: $('.action-cmd').show(); break; case '3': - case 3: $('.action-smtp').show(); break; case '8': - case 8: $('.action-dataretention').show(); break; case '9': - case 9: $('.action-fs').show(); onFsActionChanged($("#idFsActionType").val()); break; + case '11': + $('.action-pwd-expiration').show(); + break; } } @@ -943,23 +952,18 @@ along with this program. If not, see . $('.action-fs-type').hide(); switch (val) { case '1': - case 1: $('.action-fs-rename').show(); break; case '2': - case 2: $('.action-fs-delete').show(); break; case '3': - case 3: $('.action-fs-mkdir').show(); break; case '4': - case 4: $('.action-fs-exist').show(); break; case '5': - case 5: $('.action-fs-compress').show(); break; }