Browse Source

web UI/REST API: add password reset

In order to reset the password from the admin/client user interface,
an SMTP configuration must be added and the user/admin must have an email
address.
You can prohibit the reset functionality on a per-user basis by using a
specific restriction.

Fixes #597
Nicola Murino 3 years ago
parent
commit
78233ff9a3

+ 1 - 0
README.md

@@ -89,6 +89,7 @@ On Windows you can use:
 
 - The Windows installer to install and run SFTPGo as a Windows service.
 - The portable package to start SFTPGo on demand.
+- The [Chocolatey package](https://community.chocolatey.org/packages/sftpgo) to install and run SFTPGo as a Windows service.
 
 You can easily test new features selecting a commit from the [Actions](https://github.com/drakkan/sftpgo/actions) page and downloading the matching build artifacts for Linux, macOS or Windows. GitHub stores artifacts for 90 days.
 

+ 5 - 0
dataprovider/user.go

@@ -732,6 +732,11 @@ func (u *User) CanManageShares() bool {
 	return !util.IsStringInSlice(sdk.WebClientSharesDisabled, u.Filters.WebClient)
 }
 
+// CanResetPassword returns true if this user is allowed to reset its password
+func (u *User) CanResetPassword() bool {
+	return !util.IsStringInSlice(sdk.WebClientPasswordResetDisabled, u.Filters.WebClient)
+}
+
 // CanChangePassword returns true if this user is allowed to change its password
 func (u *User) CanChangePassword() bool {
 	return !util.IsStringInSlice(sdk.WebClientPasswordChangeDisabled, u.Filters.WebClient)

+ 1 - 0
docs/rate-limiting.md

@@ -63,6 +63,7 @@ You can defines how many rate limiters as you want, but keep in mind that if you
       "protocols": [
         "FTP"
       ],
+      "allow_list": [],
       "generate_defender_events": true,
       "entries_soft_limit": 100,
       "entries_hard_limit": 150

+ 9 - 9
go.mod

@@ -7,7 +7,7 @@ require (
 	github.com/Azure/azure-storage-blob-go v0.14.0
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
-	github.com/aws/aws-sdk-go v1.41.19
+	github.com/aws/aws-sdk-go v1.42.4
 	github.com/cockroachdb/cockroach-go/v2 v2.2.1
 	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
 	github.com/fclairamb/ftpserverlib v0.16.0
@@ -25,13 +25,13 @@ require (
 	github.com/hashicorp/go-retryablehttp v0.7.0
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
 	github.com/klauspost/compress v1.13.6
-	github.com/lestrrat-go/jwx v1.2.9
-	github.com/lib/pq v1.10.3
+	github.com/lestrrat-go/jwx v1.2.10
+	github.com/lib/pq v1.10.4
 	github.com/lithammer/shortuuid/v3 v3.0.7
 	github.com/mattn/go-sqlite3 v1.14.9
 	github.com/mhale/smtpd v0.8.0
 	github.com/minio/sio v0.3.0
-	github.com/otiai10/copy v1.6.0
+	github.com/otiai10/copy v1.7.0
 	github.com/pires/go-proxyproto v0.6.1
 	github.com/pkg/sftp v1.13.4
 	github.com/pquerna/otp v1.3.0
@@ -52,8 +52,8 @@ require (
 	go.uber.org/automaxprocs v1.4.0
 	gocloud.dev v0.24.0
 	golang.org/x/crypto v0.0.0-20210915214749-c084706c2272
-	golang.org/x/net v0.0.0-20211105192438-b53810dc28af
-	golang.org/x/sys v0.0.0-20211106132015-ebca88c72f68
+	golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2
+	golang.org/x/sys v0.0.0-20211113001501-0c823b97ae02
 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
 	google.golang.org/api v0.60.0
 	google.golang.org/grpc v1.42.0
@@ -128,8 +128,8 @@ require (
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247 // indirect
-	gopkg.in/ini.v1 v1.63.2 // indirect
+	google.golang.org/genproto v0.0.0-20211112145013-271947fe86fd // indirect
+	gopkg.in/ini.v1 v1.64.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
 )
@@ -138,5 +138,5 @@ replace (
 	github.com/eikenb/pipeat => github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639
 	github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
 	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20210918082254-e7eb8487714b
-	golang.org/x/net => github.com/drakkan/net v0.0.0-20211106121348-90772e49e64e
+	golang.org/x/net => github.com/drakkan/net v0.0.0-20211113113417-b46c467195fe
 )

+ 18 - 17
go.sum

@@ -137,8 +137,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
-github.com/aws/aws-sdk-go v1.41.19 h1:9QR2WTNj5bFdrNjRY9SeoG+3hwQmKXGX16851vdh+N8=
-github.com/aws/aws-sdk-go v1.41.19/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go v1.42.4 h1:L3gadqlmmdWCDE7aD52l3A5TKVG9jPBHZG1/65x9GVw=
+github.com/aws/aws-sdk-go v1.42.4/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
 github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
 github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
@@ -222,8 +222,8 @@ github.com/drakkan/crypto v0.0.0-20210918082254-e7eb8487714b h1:MZY6RAQFVhJous68
 github.com/drakkan/crypto v0.0.0-20210918082254-e7eb8487714b/go.mod h1:0hNoheD1tVu/m8WMkw/chBXf5VpwzL5fHQU25k79NKo=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
-github.com/drakkan/net v0.0.0-20211106121348-90772e49e64e h1:om9H3anUwjKmPDdAdNiVB96Fcwnt7t8B4C1f8ivrm0U=
-github.com/drakkan/net v0.0.0-20211106121348-90772e49e64e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+github.com/drakkan/net v0.0.0-20211113113417-b46c467195fe h1:+1vDan1wSEhohZ/jg7C/4hAr2ceDw0nSM1pk/lrVSLA=
+github.com/drakkan/net v0.0.0-20211113113417-b46c467195fe/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639 h1:8tfGdb4kg/YCvAbIrsMazgoNtnqdOqQVDKW12uUCuuU=
 github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
@@ -566,8 +566,8 @@ github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++
 github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
 github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
 github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
-github.com/lestrrat-go/jwx v1.2.9 h1:kS8kLI4oaBYJJ6u6rpbPI0tDYVCqo0P5u8vv1zoQ49U=
-github.com/lestrrat-go/jwx v1.2.9/go.mod h1:25DcLbNWArPA/Ew5CcBmewl32cJKxOk5cbepBsIJFzw=
+github.com/lestrrat-go/jwx v1.2.10 h1:rz6Ywm3wCRWsy2lyRZ7uHzE4E09m7X9eINaoAEVXCKw=
+github.com/lestrrat-go/jwx v1.2.10/go.mod h1:25DcLbNWArPA/Ew5CcBmewl32cJKxOk5cbepBsIJFzw=
 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/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@@ -576,8 +576,8 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
-github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk=
+github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
 github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
 github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
@@ -660,13 +660,13 @@ github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa
 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
 github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE=
-github.com/otiai10/copy v1.6.0 h1:IinKAryFFuPONZ7cm6T6E2QX/vcJwSnlaA5lfoaXIiQ=
-github.com/otiai10/copy v1.6.0/go.mod h1:XWfuS3CrI0R6IE0FbgHsEazaXO8G0LpMp9o8tos0x4E=
+github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE=
+github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
 github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
 github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
 github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
-github.com/otiai10/mint v1.3.2 h1:VYWnrP5fXmz1MXvjuUvcBrXSjGE6xjON+axB/UrpO3E=
-github.com/otiai10/mint v1.3.2/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
+github.com/otiai10/mint v1.3.3 h1:7JgpsBaN0uMkyju4tbYHu0mnM55hNKVYLsXmwr15NQI=
+github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
 github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
 github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM=
@@ -985,8 +985,8 @@ golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211106132015-ebca88c72f68 h1:Ywe/f3fNleF8I6F6qv3MeFoSZ6CTf2zBMMa/7qVML8M=
-golang.org/x/sys v0.0.0-20211106132015-ebca88c72f68/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211113001501-0c823b97ae02 h1:7NCfEGl0sfUojmX78nK9pBJuUlSZWEJA/TwASvfiPLo=
+golang.org/x/sys v0.0.0-20211113001501-0c823b97ae02/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1190,8 +1190,8 @@ google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEc
 google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247 h1:ZONpjmFT5e+I/0/xE3XXbG5OIvX2hRYzol04MhKBl2E=
-google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211112145013-271947fe86fd h1:8jqRgiTTWyKMDOM2AvhjA5dZLBSKXg1yFupPRBV/4fQ=
+google.golang.org/genproto v0.0.0-20211112145013-271947fe86fd/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@@ -1248,8 +1248,9 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8
 gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
 gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
 gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/ini.v1 v1.63.2 h1:tGK/CyBg7SMzb60vP1M03vNZ3VDu3wGQJwn7Sxi9r3c=
 gopkg.in/ini.v1 v1.63.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.64.0 h1:Mj2zXEXcNb5joEiSA0zc3HZpTst/iyjNiR4CN8tDzOg=
+gopkg.in/ini.v1 v1.64.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=

+ 34 - 0
httpd/api_admin.go

@@ -9,6 +9,7 @@ import (
 	"github.com/go-chi/render"
 
 	"github.com/drakkan/sftpgo/v2/dataprovider"
+	"github.com/drakkan/sftpgo/v2/smtp"
 	"github.com/drakkan/sftpgo/v2/util"
 )
 
@@ -214,6 +215,39 @@ func updateAdminProfile(w http.ResponseWriter, r *http.Request) {
 	sendAPIResponse(w, r, err, "Profile updated", http.StatusOK)
 }
 
+func forgotAdminPassword(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	if !smtp.IsEnabled() {
+		sendAPIResponse(w, r, nil, "No SMTP configuration", http.StatusBadRequest)
+		return
+	}
+
+	err := handleForgotPassword(r, getURLParam(r, "username"), true)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+
+	sendAPIResponse(w, r, err, "Check your email for the confirmation code", http.StatusOK)
+}
+
+func resetAdminPassword(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	var req pwdReset
+	err := render.DecodeJSON(r.Body, &req)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+	_, _, err = handleResetPassword(r, req.Code, req.Password, true)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	sendAPIResponse(w, r, err, "Password reset successful", http.StatusOK)
+}
+
 func changeAdminPassword(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 

+ 35 - 0
httpd/api_user.go

@@ -12,6 +12,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/sdk"
+	"github.com/drakkan/sftpgo/v2/smtp"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/vfs"
 )
@@ -186,6 +187,40 @@ func deleteUser(w http.ResponseWriter, r *http.Request) {
 	disconnectUser(username)
 }
 
+func forgotUserPassword(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	if !smtp.IsEnabled() {
+		sendAPIResponse(w, r, nil, "No SMTP configuration", http.StatusBadRequest)
+		return
+	}
+
+	err := handleForgotPassword(r, getURLParam(r, "username"), false)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+
+	sendAPIResponse(w, r, err, "Check your email for the confirmation code", http.StatusOK)
+}
+
+func resetUserPassword(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	var req pwdReset
+	err := render.DecodeJSON(r.Body, &req)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+	_, _, err = handleResetPassword(r, req.Code, req.Password, false)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	sendAPIResponse(w, r, err, "Password reset successful", http.StatusOK)
+}
+
 func disconnectUser(username string) {
 	for _, stat := range common.Connections.GetStats() {
 		if stat.Username == username {

+ 129 - 0
httpd/api_utils.go

@@ -1,6 +1,7 @@
 package httpd
 
 import (
+	"bytes"
 	"context"
 	"errors"
 	"fmt"
@@ -15,6 +16,7 @@ import (
 	"time"
 
 	"github.com/go-chi/chi/v5"
+	"github.com/go-chi/chi/v5/middleware"
 	"github.com/go-chi/render"
 	"github.com/klauspost/compress/zip"
 
@@ -23,6 +25,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/metric"
 	"github.com/drakkan/sftpgo/v2/sdk/plugin"
+	"github.com/drakkan/sftpgo/v2/smtp"
 	"github.com/drakkan/sftpgo/v2/util"
 )
 
@@ -31,6 +34,11 @@ type pwdChange struct {
 	NewPassword     string `json:"new_password"`
 }
 
+type pwdReset struct {
+	Code     string `json:"code"`
+	Password string `json:"password"`
+}
+
 type baseProfile struct {
 	Email           string `json:"email,omitempty"`
 	Description     string `json:"description,omitempty"`
@@ -455,3 +463,124 @@ func checkHTTPClientUser(user *dataprovider.User, r *http.Request, connectionID
 	}
 	return nil
 }
+
+func handleForgotPassword(r *http.Request, username string, isAdmin bool) error {
+	var email, subject string
+	var err error
+	var admin dataprovider.Admin
+	var user dataprovider.User
+
+	if username == "" {
+		return util.NewValidationError("Username is mandatory")
+	}
+	if isAdmin {
+		admin, err = dataprovider.AdminExists(username)
+		email = admin.Email
+		subject = fmt.Sprintf("Email Verification Code for admin %#v", username)
+	} else {
+		user, err = dataprovider.UserExists(username)
+		email = user.Email
+		subject = fmt.Sprintf("Email Verification Code for user %#v", username)
+		if err == nil {
+			if !isUserAllowedToResetPassword(r, &user) {
+				return util.NewValidationError("You are not allowed to reset your password")
+			}
+		}
+	}
+	if err != nil {
+		if _, ok := err.(*util.RecordNotFoundError); ok {
+			logger.Debug(logSender, middleware.GetReqID(r.Context()), "username %#v does not exists, reset password request silently ignored, is admin? %v",
+				username, isAdmin)
+			return nil
+		}
+		return util.NewGenericError("Error retrieving your account, please try again later")
+	}
+	if email == "" {
+		return util.NewValidationError("Your account does not have an email address, it is not possible to reset your password by sending an email verification code")
+	}
+	c := newResetCode(username, isAdmin)
+	body := new(bytes.Buffer)
+	data := make(map[string]string)
+	data["Code"] = c.Code
+	if err := smtp.RenderPasswordResetTemplate(body, data); err != nil {
+		logger.Warn(logSender, middleware.GetReqID(r.Context()), "unable to render password reset template: %v", err)
+		return util.NewGenericError("Unable to render password reset template")
+	}
+	startTime := time.Now()
+	if err := smtp.SendEmail(email, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
+		logger.Warn(logSender, middleware.GetReqID(r.Context()), "unable to send password reset code via email: %v, elapsed: %v",
+			err, time.Since(startTime))
+		return util.NewGenericError(fmt.Sprintf("Unable to send confirmation code via email: %v", err))
+	}
+	logger.Debug(logSender, middleware.GetReqID(r.Context()), "reset code sent via email to %#v, email: %#v, is admin? %v, elapsed: %v",
+		username, email, isAdmin, time.Since(startTime))
+	resetCodes.Store(c.Code, c)
+	return nil
+}
+
+func handleResetPassword(r *http.Request, code, newPassword string, isAdmin bool) (
+	*dataprovider.Admin, *dataprovider.User, error,
+) {
+	var admin dataprovider.Admin
+	var user dataprovider.User
+	var err error
+
+	if newPassword == "" {
+		return &admin, &user, util.NewValidationError("Please set a password")
+	}
+	if code == "" {
+		return &admin, &user, util.NewValidationError("Please set a confirmation code")
+	}
+	c, ok := resetCodes.Load(code)
+	if !ok {
+		return &admin, &user, util.NewValidationError("Confirmation code not found")
+	}
+	resetCode := c.(*resetCode)
+	if resetCode.IsAdmin != isAdmin {
+		return &admin, &user, util.NewValidationError("Invalid confirmation code")
+	}
+	if isAdmin {
+		admin, err = dataprovider.AdminExists(resetCode.Username)
+		if err != nil {
+			return &admin, &user, util.NewValidationError("Unable to associate the confirmation code with an existing admin")
+		}
+		admin.Password = newPassword
+		err = dataprovider.UpdateAdmin(&admin, admin.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+		if err != nil {
+			return &admin, &user, util.NewGenericError(fmt.Sprintf("Unable to set the new password: %v", err))
+		}
+	} else {
+		user, err = dataprovider.UserExists(resetCode.Username)
+		if err != nil {
+			return &admin, &user, util.NewValidationError("Unable to associate the confirmation code with an existing user")
+		}
+		if err == nil {
+			if !isUserAllowedToResetPassword(r, &user) {
+				return &admin, &user, util.NewValidationError("You are not allowed to reset your password")
+			}
+		}
+		user.Password = newPassword
+		err = dataprovider.UpdateUser(&user, user.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+		if err != nil {
+			return &admin, &user, util.NewGenericError(fmt.Sprintf("Unable to set the new password: %v", err))
+		}
+	}
+	resetCodes.Delete(code)
+	return &admin, &user, nil
+}
+
+func isUserAllowedToResetPassword(r *http.Request, user *dataprovider.User) bool {
+	if !user.CanResetPassword() {
+		return false
+	}
+	if util.IsStringInSlice(common.ProtocolHTTP, user.Filters.DeniedProtocols) {
+		return false
+	}
+	if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) {
+		return false
+	}
+	if !user.IsLoginFromAddrAllowed(r.RemoteAddr) {
+		return false
+	}
+	return true
+}

+ 14 - 1
httpd/httpd.go

@@ -104,6 +104,8 @@ const (
 	webScanVFolderPathDefault             = "/web/admin/quotas/scanfolder"
 	webQuotaScanPathDefault               = "/web/admin/quotas/scanuser"
 	webChangeAdminPwdPathDefault          = "/web/admin/changepwd"
+	webAdminForgotPwdPathDefault          = "/web/admin/forgot-password"
+	webAdminResetPwdPathDefault           = "/web/admin/reset-password"
 	webAdminProfilePathDefault            = "/web/admin/profile"
 	webAdminMFAPathDefault                = "/web/admin/mfa"
 	webAdminTOTPGeneratePathDefault       = "/web/admin/totp/generate"
@@ -132,6 +134,8 @@ const (
 	webChangeClientPwdPathDefault         = "/web/client/changepwd"
 	webClientLogoutPathDefault            = "/web/client/logout"
 	webClientPubSharesPathDefault         = "/web/client/pubshares"
+	webClientForgotPwdPathDefault         = "/web/client/forgot-password"
+	webClientResetPwdPathDefault          = "/web/client/reset-password"
 	webStaticFilesPathDefault             = "/static"
 	// MaxRestoreSize defines the max size for the loaddata input file
 	MaxRestoreSize       = 10485760 // 10 MB
@@ -179,6 +183,8 @@ var (
 	webAdminTOTPSavePath           string
 	webAdminRecoveryCodesPath      string
 	webChangeAdminPwdPath          string
+	webAdminForgotPwdPath          string
+	webAdminResetPwdPath           string
 	webTemplateUser                string
 	webTemplateFolder              string
 	webDefenderPath                string
@@ -201,6 +207,8 @@ var (
 	webClientRecoveryCodesPath     string
 	webClientPubSharesPath         string
 	webClientLogoutPath            string
+	webClientForgotPwdPath         string
+	webClientResetPwdPath          string
 	webStaticFilesPath             string
 	// max upload size for http clients, 1GB by default
 	maxUploadFileSize = int64(1048576000)
@@ -455,7 +463,7 @@ func (c *Conf) Initialize(configDir string) error {
 	}
 
 	maxUploadFileSize = c.MaxUploadFileSize
-	startCleanupTicker(tokenDuration)
+	startCleanupTicker(tokenDuration / 2)
 	return <-exitChannel
 }
 
@@ -539,6 +547,8 @@ func updateWebClientURLs(baseURL string) {
 	webClientTOTPValidatePath = path.Join(baseURL, webClientTOTPValidatePathDefault)
 	webClientTOTPSavePath = path.Join(baseURL, webClientTOTPSavePathDefault)
 	webClientRecoveryCodesPath = path.Join(baseURL, webClientRecoveryCodesPathDefault)
+	webClientForgotPwdPath = path.Join(baseURL, webClientForgotPwdPathDefault)
+	webClientResetPwdPath = path.Join(baseURL, webClientResetPwdPathDefault)
 }
 
 func updateWebAdminURLs(baseURL string) {
@@ -567,6 +577,8 @@ func updateWebAdminURLs(baseURL string) {
 	webScanVFolderPath = path.Join(baseURL, webScanVFolderPathDefault)
 	webQuotaScanPath = path.Join(baseURL, webQuotaScanPathDefault)
 	webChangeAdminPwdPath = path.Join(baseURL, webChangeAdminPwdPathDefault)
+	webAdminForgotPwdPath = path.Join(baseURL, webAdminForgotPwdPathDefault)
+	webAdminResetPwdPath = path.Join(baseURL, webAdminResetPwdPathDefault)
 	webAdminProfilePath = path.Join(baseURL, webAdminProfilePathDefault)
 	webAdminMFAPath = path.Join(baseURL, webAdminMFAPathDefault)
 	webAdminTOTPGeneratePath = path.Join(baseURL, webAdminTOTPGeneratePathDefault)
@@ -606,6 +618,7 @@ func startCleanupTicker(duration time.Duration) {
 				return
 			case <-cleanupTicker.C:
 				cleanupExpiredJWTTokens()
+				cleanupExpiredResetCodes()
 			}
 		}
 	}()

+ 679 - 2
httpd/httpd_test.go

@@ -16,6 +16,7 @@ import (
 	"os"
 	"path"
 	"path/filepath"
+	"regexp"
 	"runtime"
 	"strconv"
 	"strings"
@@ -27,12 +28,14 @@ import (
 	_ "github.com/go-sql-driver/mysql"
 	_ "github.com/lib/pq"
 	_ "github.com/mattn/go-sqlite3"
+	"github.com/mhale/smtpd"
 	"github.com/pquerna/otp"
 	"github.com/pquerna/otp/totp"
 	"github.com/rs/xid"
 	"github.com/rs/zerolog"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/net/html"
 
 	"github.com/drakkan/sftpgo/v2/common"
@@ -47,6 +50,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/sdk"
 	"github.com/drakkan/sftpgo/v2/sdk/plugin"
 	"github.com/drakkan/sftpgo/v2/sftpd"
+	"github.com/drakkan/sftpgo/v2/smtp"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/vfs"
 )
@@ -129,6 +133,8 @@ const (
 	webAdminTwoFactorRecoveryPath   = "/web/admin/twofactor-recovery"
 	webAdminMFAPath                 = "/web/admin/mfa"
 	webAdminTOTPSavePath            = "/web/admin/totp/save"
+	webAdminForgotPwdPath           = "/web/admin/forgot-password"
+	webAdminResetPwdPath            = "/web/admin/reset-password"
 	webBasePathClient               = "/web/client"
 	webClientLoginPath              = "/web/client/login"
 	webClientFilesPath              = "/web/client/files"
@@ -145,8 +151,11 @@ const (
 	webClientSharesPath             = "/web/client/shares"
 	webClientSharePath              = "/web/client/share"
 	webClientPubSharesPath          = "/web/client/pubshares"
+	webClientForgotPwdPath          = "/web/client/forgot-password"
+	webClientResetPwdPath           = "/web/client/reset-password"
 	httpBaseURL                     = "http://127.0.0.1:8081"
 	sftpServerAddr                  = "127.0.0.1:8022"
+	smtpServerAddr                  = "127.0.0.1:3525"
 	configDir                       = ".."
 	httpsCert                       = `-----BEGIN CERTIFICATE-----
 MIICHTCCAaKgAwIBAgIUHnqw7QnB1Bj9oUsNpdb+ZkFPOxMwCgYIKoZIzj0EAwIw
@@ -192,6 +201,7 @@ var (
 	providerDriverName string
 	postConnectPath    string
 	preActionPath      string
+	lastResetCode      string
 )
 
 type fakeConnection struct {
@@ -348,6 +358,8 @@ func TestMain(m *testing.M) {
 		}
 	}()
 
+	startSMTPServer()
+
 	waitTCPListening(httpdConf.Bindings[0].GetAddress())
 	waitTCPListening(sftpdConf.Bindings[0].GetAddress())
 	httpd.ReloadCertificateMgr() //nolint:errcheck
@@ -383,7 +395,7 @@ func TestMain(m *testing.M) {
 	defer testServer.Close()
 
 	exitCode := m.Run()
-	//os.Remove(logfilePath)
+	os.Remove(logfilePath)
 	os.RemoveAll(backupsPath)
 	os.RemoveAll(credentialsPath)
 	os.Remove(certPath)
@@ -3393,7 +3405,14 @@ func TestCloseConnectionAfterUserUpdateDelete(t *testing.T) {
 }
 
 func TestSkipNaturalKeysValidation(t *testing.T) {
-	err := dataprovider.Close()
+	smtpCfg := smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          3525,
+		TemplatesPath: "templates",
+	}
+	err := smtpCfg.Initialize("..")
+	require.NoError(t, err)
+	err = dataprovider.Close()
 	assert.NoError(t, err)
 	err = config.LoadConfig(configDir, "")
 	assert.NoError(t, err)
@@ -3404,6 +3423,7 @@ func TestSkipNaturalKeysValidation(t *testing.T) {
 
 	u := getTestUser()
 	u.Username = "user@user.me"
+	u.Email = u.Username
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
 	user.AdditionalInfo = "info"
@@ -3463,6 +3483,27 @@ func TestSkipNaturalKeysValidation(t *testing.T) {
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	assert.Contains(t, rr.Body.String(), "the following characters are allowed")
+	// test user reset password
+	form = make(url.Values)
+	form.Set("username", user.Username)
+	form.Set(csrfFormToken, csrfToken)
+	lastResetCode = ""
+	req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusFound, rr.Code)
+	assert.Greater(t, len(lastResetCode), 20)
+	form = make(url.Values)
+	form.Set(csrfFormToken, csrfToken)
+	form.Set("code", lastResetCode)
+	form.Set("password", defaultPassword)
+	req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Unable to set the new password")
 
 	adminAPIToken, err := getJWTAPITokenFromTestServer(admin.Username, defaultTokenAuthPass)
 	assert.NoError(t, err)
@@ -3535,9 +3576,34 @@ func TestSkipNaturalKeysValidation(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	assert.Contains(t, rr.Body.String(), "the following characters are allowed")
+	// test admin reset password
+	form = make(url.Values)
+	form.Set("username", admin.Username)
+	form.Set(csrfFormToken, csrfToken)
+	lastResetCode = ""
+	req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusFound, rr.Code)
+	assert.Greater(t, len(lastResetCode), 20)
+	form = make(url.Values)
+	form.Set(csrfFormToken, csrfToken)
+	form.Set("code", lastResetCode)
+	form.Set("password", defaultPassword)
+	req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Unable to set the new password")
 
 	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
 	assert.NoError(t, err)
+
+	smtpCfg = smtp.Config{}
+	err = smtpCfg.Initialize("..")
+	require.NoError(t, err)
 }
 
 func TestSaveErrors(t *testing.T) {
@@ -3775,6 +3841,19 @@ func TestProviderErrors(t *testing.T) {
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusInternalServerError, rr)
 
+	// password reset errors
+	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+	assert.NoError(t, err)
+	form := make(url.Values)
+	form.Set("username", "username")
+	form.Set(csrfFormToken, csrfToken)
+	req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Error retrieving your account, please try again later")
+
 	req, err = http.NewRequest(http.MethodGet, webClientSharesPath, nil)
 	assert.NoError(t, err)
 	setJWTCookieForReq(req, userWebToken)
@@ -8078,6 +8157,7 @@ func TestPostConnectHook(t *testing.T) {
 func TestMaxSessions(t *testing.T) {
 	u := getTestUser()
 	u.MaxSessions = 1
+	u.Email = "user@session.com"
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
 	assert.NoError(t, err)
 	_, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
@@ -8094,6 +8174,42 @@ func TestMaxSessions(t *testing.T) {
 	assert.Error(t, err)
 	_, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.Error(t, err)
+	// test reset password
+	smtpCfg := smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          3525,
+		TemplatesPath: "templates",
+	}
+	err = smtpCfg.Initialize("..")
+	assert.NoError(t, err)
+
+	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+	assert.NoError(t, err)
+	form := make(url.Values)
+	form.Set(csrfFormToken, csrfToken)
+	form.Set("username", user.Username)
+	lastResetCode = ""
+	req, err := http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr := executeRequest(req)
+	assert.Equal(t, http.StatusFound, rr.Code)
+	assert.Greater(t, len(lastResetCode), 20)
+	form = make(url.Values)
+	form.Set(csrfFormToken, csrfToken)
+	form.Set("password", defaultPassword)
+	form.Set("code", lastResetCode)
+	req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Password reset successfully but unable to login")
+
+	smtpCfg = smtp.Config{}
+	err = smtpCfg.Initialize("..")
+	require.NoError(t, err)
+
 	common.Connections.Remove(connection.GetID())
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
@@ -8102,6 +8218,83 @@ func TestMaxSessions(t *testing.T) {
 	assert.Len(t, common.Connections.GetStats(), 0)
 }
 
+func TestSFTPLoopError(t *testing.T) {
+	user1 := getTestUser()
+	user2 := getTestUser()
+	user1.Username += "1"
+	user1.Email = "user1@test.com"
+	user2.Username += "2"
+	user1.FsConfig = vfs.Filesystem{
+		Provider: sdk.SFTPFilesystemProvider,
+		SFTPConfig: vfs.SFTPFsConfig{
+			SFTPFsConfig: sdk.SFTPFsConfig{
+				Endpoint: sftpServerAddr,
+				Username: user2.Username,
+				Password: kms.NewPlainSecret(defaultPassword),
+			},
+		},
+	}
+
+	user2.FsConfig.Provider = sdk.SFTPFilesystemProvider
+	user2.FsConfig.SFTPConfig = vfs.SFTPFsConfig{
+		SFTPFsConfig: sdk.SFTPFsConfig{
+			Endpoint: sftpServerAddr,
+			Username: user1.Username,
+			Password: kms.NewPlainSecret(defaultPassword),
+		},
+	}
+
+	user1, resp, err := httpdtest.AddUser(user1, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+	user2, resp, err = httpdtest.AddUser(user2, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+
+	// test reset password
+	smtpCfg := smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          3525,
+		TemplatesPath: "templates",
+	}
+	err = smtpCfg.Initialize("..")
+	assert.NoError(t, err)
+
+	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+	assert.NoError(t, err)
+	form := make(url.Values)
+	form.Set(csrfFormToken, csrfToken)
+	form.Set("username", user1.Username)
+	lastResetCode = ""
+	req, err := http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr := executeRequest(req)
+	assert.Equal(t, http.StatusFound, rr.Code)
+	assert.Greater(t, len(lastResetCode), 20)
+	form = make(url.Values)
+	form.Set(csrfFormToken, csrfToken)
+	form.Set("password", defaultPassword)
+	form.Set("code", lastResetCode)
+	req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Password reset successfully but unable to login")
+
+	smtpCfg = smtp.Config{}
+	err = smtpCfg.Initialize("..")
+	require.NoError(t, err)
+
+	_, err = httpdtest.RemoveUser(user1, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user1.GetHomeDir())
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user2, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user2.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestLoginInvalidFs(t *testing.T) {
 	u := getTestUser()
 	u.Filters.AllowAPIKeyAuth = true
@@ -14050,6 +14243,475 @@ func TestWebFoldersMock(t *testing.T) {
 	}
 }
 
+func TestAdminForgotPassword(t *testing.T) {
+	smtpCfg := smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          3525,
+		TemplatesPath: "templates",
+	}
+	err := smtpCfg.Initialize("..")
+	require.NoError(t, err)
+
+	a := getTestAdmin()
+	a.Username = altAdminUsername
+	a.Password = altAdminPassword
+	admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, webAdminForgotPwdPath, nil)
+	assert.NoError(t, err)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, webAdminResetPwdPath, nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, webLoginPath, nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+	assert.NoError(t, err)
+
+	form := make(url.Values)
+	form.Set("username", "")
+	// no csrf token
+	req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusForbidden, rr.Code)
+	// empty username
+	form.Set(csrfFormToken, csrfToken)
+	req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Username is mandatory")
+
+	lastResetCode = ""
+	form.Set("username", altAdminUsername)
+	req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusFound, rr.Code)
+	assert.Greater(t, len(lastResetCode), 20)
+
+	form = make(url.Values)
+	req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusForbidden, rr.Code)
+	// no password
+	form.Set(csrfFormToken, csrfToken)
+	req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Please set a password")
+	// no code
+	form.Set("password", defaultPassword)
+	req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Please set a confirmation code")
+	// ok
+	form.Set("code", lastResetCode)
+	req, err = http.NewRequest(http.MethodPost, webAdminResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusFound, rr.Code)
+
+	form.Set("username", altAdminUsername)
+	req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusFound, rr.Code)
+	assert.Greater(t, len(lastResetCode), 20)
+
+	// not working smtp server
+	smtpCfg = smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          3526,
+		TemplatesPath: "templates",
+	}
+	err = smtpCfg.Initialize("..")
+	require.NoError(t, err)
+
+	form = make(url.Values)
+	form.Set("username", altAdminUsername)
+	form.Set(csrfFormToken, csrfToken)
+	req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Unable to send confirmation code via email")
+
+	smtpCfg = smtp.Config{}
+	err = smtpCfg.Initialize("..")
+	require.NoError(t, err)
+
+	form.Set("username", altAdminUsername)
+	form.Set(csrfFormToken, csrfToken)
+	req, err = http.NewRequest(http.MethodPost, webAdminForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Unable to render password reset template")
+
+	req, err = http.NewRequest(http.MethodGet, webAdminForgotPwdPath, nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodGet, webAdminResetPwdPath, nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
+	assert.NoError(t, err)
+}
+
+func TestUserForgotPassword(t *testing.T) {
+	smtpCfg := smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          3525,
+		TemplatesPath: "templates",
+	}
+	err := smtpCfg.Initialize("..")
+	require.NoError(t, err)
+
+	u := getTestUser()
+	u.Email = "user@test.com"
+	u.Filters.WebClient = []string{sdk.WebClientPasswordResetDisabled}
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, webClientForgotPwdPath, nil)
+	assert.NoError(t, err)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, webClientResetPwdPath, nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, webClientLoginPath, nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	form := make(url.Values)
+	form.Set("username", "")
+	// no csrf token
+	req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusForbidden, rr.Code)
+	// empty username
+	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
+	assert.NoError(t, err)
+	form.Set(csrfFormToken, csrfToken)
+	req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Username is mandatory")
+	// user cannot reset the password
+	form.Set("username", user.Username)
+	req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "You are not allowed to reset your password")
+	user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled}
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+
+	lastResetCode = ""
+	req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusFound, rr.Code)
+	assert.Greater(t, len(lastResetCode), 20)
+	// no csrf token
+	form = make(url.Values)
+	req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusForbidden, rr.Code)
+	// no password
+	form.Set(csrfFormToken, csrfToken)
+	req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Please set a password")
+	// no code
+	form.Set("password", altAdminPassword)
+	req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Please set a confirmation code")
+	// ok
+	form.Set("code", lastResetCode)
+	req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusFound, rr.Code)
+
+	form = make(url.Values)
+	form.Set(csrfFormToken, csrfToken)
+	form.Set("username", user.Username)
+	lastResetCode = ""
+	req, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusFound, rr.Code)
+	assert.Greater(t, len(lastResetCode), 20)
+
+	smtpCfg = smtp.Config{}
+	err = smtpCfg.Initialize("..")
+	require.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, webClientForgotPwdPath, nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	req, err = http.NewRequest(http.MethodGet, webClientResetPwdPath, nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusNotFound, rr)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	// user does not exist anymore
+	form = make(url.Values)
+	form.Set(csrfFormToken, csrfToken)
+	form.Set("code", lastResetCode)
+	form.Set("password", "pwd")
+	req, err = http.NewRequest(http.MethodPost, webClientResetPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = executeRequest(req)
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Unable to associate the confirmation code with an existing user")
+}
+
+func TestAPIForgotPassword(t *testing.T) {
+	smtpCfg := smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          3525,
+		TemplatesPath: "templates",
+	}
+	err := smtpCfg.Initialize("..")
+	require.NoError(t, err)
+
+	a := getTestAdmin()
+	a.Username = altAdminUsername
+	a.Password = altAdminPassword
+	a.Email = ""
+	admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated)
+	assert.NoError(t, err)
+	// no email, forgot pwd will not work
+	lastResetCode = ""
+	req, err := http.NewRequest(http.MethodPost, path.Join(adminPath, altAdminUsername, "/forgot-password"), nil)
+	assert.NoError(t, err)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Your account does not have an email address")
+
+	admin.Email = "admin@test.com"
+	admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK)
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodPost, path.Join(adminPath, altAdminUsername, "/forgot-password"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Greater(t, len(lastResetCode), 20)
+
+	// invalid JSON
+	req, err = http.NewRequest(http.MethodPost, path.Join(adminPath, altAdminUsername, "/reset-password"), bytes.NewBuffer([]byte(`{`)))
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+
+	resetReq := make(map[string]string)
+	resetReq["code"] = lastResetCode
+	resetReq["password"] = defaultPassword
+	asJSON, err := json.Marshal(resetReq)
+	assert.NoError(t, err)
+
+	// a user cannot use an admin code
+	req, err = http.NewRequest(http.MethodPost, path.Join(userPath, defaultUsername, "/reset-password"), bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Invalid confirmation code")
+
+	req, err = http.NewRequest(http.MethodPost, path.Join(adminPath, altAdminUsername, "/reset-password"), bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	// the same code cannot be reused
+	req, err = http.NewRequest(http.MethodPost, path.Join(adminPath, altAdminUsername, "/reset-password"), bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Confirmation code not found")
+
+	admin, err = dataprovider.AdminExists(altAdminUsername)
+	assert.NoError(t, err)
+
+	match, err := admin.CheckPassword(defaultPassword)
+	assert.NoError(t, err)
+	assert.True(t, match)
+	lastResetCode = ""
+	// now the same for a user
+	u := getTestUser()
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, path.Join(userPath, defaultUsername, "/forgot-password"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Your account does not have an email address")
+
+	user.Email = "user@test.com"
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, path.Join(userPath, defaultUsername, "/forgot-password"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Greater(t, len(lastResetCode), 20)
+
+	// invalid JSON
+	req, err = http.NewRequest(http.MethodPost, path.Join(userPath, defaultUsername, "/reset-password"), bytes.NewBuffer([]byte(`{`)))
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	// remove the reset password permission
+	user.Filters.WebClient = []string{sdk.WebClientPasswordResetDisabled}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+
+	resetReq["code"] = lastResetCode
+	resetReq["password"] = altAdminPassword
+	asJSON, err = json.Marshal(resetReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, path.Join(userPath, defaultUsername, "/reset-password"), bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "You are not allowed to reset your password")
+
+	user.Filters.WebClient = []string{sdk.WebClientSharesDisabled}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, path.Join(userPath, defaultUsername, "/reset-password"), bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	// the same code cannot be reused
+	req, err = http.NewRequest(http.MethodPost, path.Join(userPath, defaultUsername, "/reset-password"), bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Confirmation code not found")
+
+	user, err = dataprovider.UserExists(defaultUsername)
+	assert.NoError(t, err)
+	err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(altAdminPassword))
+	assert.NoError(t, err)
+
+	lastResetCode = ""
+	// a request for a missing admin/user will be silently ignored
+	req, err = http.NewRequest(http.MethodPost, path.Join(adminPath, "missing-admin", "/forgot-password"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Empty(t, lastResetCode)
+
+	req, err = http.NewRequest(http.MethodPost, path.Join(userPath, "missing-user", "/forgot-password"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Empty(t, lastResetCode)
+
+	lastResetCode = ""
+	req, err = http.NewRequest(http.MethodPost, path.Join(adminPath, altAdminUsername, "/forgot-password"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Greater(t, len(lastResetCode), 20)
+
+	smtpCfg = smtp.Config{}
+	err = smtpCfg.Initialize("..")
+	require.NoError(t, err)
+
+	// without an smtp configuration reset password is not available
+	req, err = http.NewRequest(http.MethodPost, path.Join(adminPath, altAdminUsername, "/forgot-password"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "No SMTP configuration")
+
+	req, err = http.NewRequest(http.MethodPost, path.Join(userPath, defaultUsername, "/forgot-password"), nil)
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "No SMTP configuration")
+
+	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
+	assert.NoError(t, err)
+	// the admin does not exist anymore
+	resetReq["code"] = lastResetCode
+	resetReq["password"] = altAdminPassword
+	asJSON, err = json.Marshal(resetReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPost, path.Join(adminPath, altAdminUsername, "/reset-password"), bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Unable to associate the confirmation code with an existing admin")
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestProviderClosedMock(t *testing.T) {
 	token, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)
@@ -14206,6 +14868,21 @@ func waitTCPListening(address string) {
 	}
 }
 
+func startSMTPServer() {
+	go func() {
+		if err := smtpd.ListenAndServe(smtpServerAddr, func(remoteAddr net.Addr, from string, to []string, data []byte) error {
+			re := regexp.MustCompile(`code is ".*?"`)
+			code := strings.TrimPrefix(string(re.Find(data)), "code is ")
+			lastResetCode = strings.ReplaceAll(code, "\"", "")
+			return nil
+		}, "SFTPGo test", "localhost"); err != nil {
+			logger.ErrorToConsole("could not start SMTP server: %v", err)
+			os.Exit(1)
+		}
+	}()
+	waitTCPListening(smtpServerAddr)
+}
+
 func getTestAdmin() dataprovider.Admin {
 	return dataprovider.Admin{
 		Username:    defaultTokenAuthUser,

+ 57 - 0
httpd/internal_test.go

@@ -796,6 +796,34 @@ func TestCreateTokenError(t *testing.T) {
 	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
 	assert.Contains(t, rr.Body.String(), "invalid URL escape")
 
+	req, _ = http.NewRequest(http.MethodPost, webAdminForgotPwdPath+"?a=a%C3%A1%GD", bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = httptest.NewRecorder()
+	handleWebAdminForgotPwdPost(rr, req)
+	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+	assert.Contains(t, rr.Body.String(), "invalid URL escape")
+
+	req, _ = http.NewRequest(http.MethodPost, webClientForgotPwdPath+"?a=a%C2%A1%GD", bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = httptest.NewRecorder()
+	handleWebClientForgotPwdPost(rr, req)
+	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+	assert.Contains(t, rr.Body.String(), "invalid URL escape")
+
+	req, _ = http.NewRequest(http.MethodPost, webAdminResetPwdPath+"?a=a%C3%AO%JD", bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = httptest.NewRecorder()
+	server.handleWebAdminPasswordResetPost(rr, req)
+	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+	assert.Contains(t, rr.Body.String(), "invalid URL escape")
+
+	req, _ = http.NewRequest(http.MethodPost, webClientResetPwdPath+"?a=a%C3%AO%JD", bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	rr = httptest.NewRecorder()
+	server.handleWebClientPasswordResetPost(rr, req)
+	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+	assert.Contains(t, rr.Body.String(), "invalid URL escape")
+
 	req, _ = http.NewRequest(http.MethodPost, webChangeClientPwdPath+"?a=a%K3%AO%GA", bytes.NewBuffer([]byte(form.Encode())))
 
 	_, err = getShareFromPostFields(req)
@@ -2040,3 +2068,32 @@ func TestLoginLinks(t *testing.T) {
 	assert.False(t, b.showAdminLoginURL())
 	assert.True(t, b.showClientLoginURL())
 }
+
+func TestResetCodesCleanup(t *testing.T) {
+	resetCode := newResetCode(util.GenerateUniqueID(), false)
+	resetCode.ExpiresAt = time.Now().Add(-1 * time.Minute).UTC()
+	resetCodes.Store(resetCode.Code, resetCode)
+	cleanupExpiredResetCodes()
+	_, ok := resetCodes.Load(resetCode.Code)
+	assert.False(t, ok)
+}
+
+func TestUserCanResetPassword(t *testing.T) {
+	req, err := http.NewRequest(http.MethodGet, webClientLoginPath, nil)
+	assert.NoError(t, err)
+	req.RemoteAddr = "172.16.9.2:55080"
+
+	u := dataprovider.User{}
+	assert.True(t, isUserAllowedToResetPassword(req, &u))
+	u.Filters.DeniedProtocols = []string{common.ProtocolHTTP}
+	assert.False(t, isUserAllowedToResetPassword(req, &u))
+	u.Filters.DeniedProtocols = nil
+	u.Filters.WebClient = []string{sdk.WebClientPasswordResetDisabled}
+	assert.False(t, isUserAllowedToResetPassword(req, &u))
+	u.Filters.WebClient = nil
+	u.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword}
+	assert.False(t, isUserAllowedToResetPassword(req, &u))
+	u.Filters.DeniedLoginMethods = nil
+	u.Filters.AllowedIP = []string{"127.0.0.1/8"}
+	assert.False(t, isUserAllowedToResetPassword(req, &u))
+}

+ 43 - 0
httpd/resetcode.go

@@ -0,0 +1,43 @@
+package httpd
+
+import (
+	"sync"
+	"time"
+
+	"github.com/drakkan/sftpgo/v2/util"
+)
+
+var (
+	resetCodeLifespan = 10 * time.Minute
+	resetCodes        sync.Map
+)
+
+type resetCode struct {
+	Code      string
+	Username  string
+	IsAdmin   bool
+	ExpiresAt time.Time
+}
+
+func (c *resetCode) isExpired() bool {
+	return c.ExpiresAt.Before(time.Now().UTC())
+}
+
+func newResetCode(username string, isAdmin bool) *resetCode {
+	return &resetCode{
+		Code:      util.GenerateUniqueID(),
+		Username:  username,
+		IsAdmin:   isAdmin,
+		ExpiresAt: time.Now().Add(resetCodeLifespan).UTC(),
+	}
+}
+
+func cleanupExpiredResetCodes() {
+	resetCodes.Range(func(key, value interface{}) bool {
+		c, ok := value.(*resetCode)
+		if !ok || c.isExpired() {
+			resetCodes.Delete(key)
+		}
+		return true
+	})
+}

+ 160 - 0
httpd/schema/openapi.yaml

@@ -2235,6 +2235,85 @@ paths:
           $ref: '#/components/responses/InternalServerError'
         default:
           $ref: '#/components/responses/DefaultResponse'
+  '/admins/{username}/forgot-password':
+    parameters:
+      - name: username
+        in: path
+        description: the admin username
+        required: true
+        schema:
+          type: string
+    post:
+      security: []
+      tags:
+        - admins
+      summary: Send a password reset code by email
+      description: 'You must set up an SMTP server and the account must have a valid email address, in which case SFTPGo will send a code via email to reset the password. If the specified admin does not exist, the request will be silently ignored (a success response will be returned) to avoid disclosing existing admins'
+      operationId: admin_forgot_password
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  '/admins/{username}/reset-password':
+    parameters:
+      - name: username
+        in: path
+        description: the admin username
+        required: true
+        schema:
+          type: string
+    post:
+      security: []
+      tags:
+        - admins
+      summary: Reset the password
+      description: 'Set a new password using the code received via email'
+      operationId: admin_reset_password
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                code:
+                  type: string
+                password:
+                  type: string
+        required: true
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /users:
     get:
       tags:
@@ -2457,6 +2536,85 @@ paths:
           $ref: '#/components/responses/InternalServerError'
         default:
           $ref: '#/components/responses/DefaultResponse'
+  '/users/{username}/forgot-password':
+    parameters:
+      - name: username
+        in: path
+        description: the username
+        required: true
+        schema:
+          type: string
+    post:
+      security: []
+      tags:
+        - users
+      summary: Send a password reset code by email
+      description: 'You must configure an SMTP server, the account must have a valid email address and must not have the "reset-password-disabled" restriction, in which case SFTPGo will send a code via email to reset the password. If the specified user does not exist, the request will be silently ignored (a success response will be returned) to avoid disclosing existing users'
+      operationId: user_forgot_password
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  '/users/{username}/reset-password':
+    parameters:
+      - name: username
+        in: path
+        description: the username
+        required: true
+        schema:
+          type: string
+    post:
+      security: []
+      tags:
+        - users
+      summary: Reset the password
+      description: 'Set a new password using the code received via email'
+      operationId: user_reset_password
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                code:
+                  type: string
+                password:
+                  type: string
+        required: true
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /status:
     get:
       tags:
@@ -3751,6 +3909,7 @@ components:
         - api-key-auth-change-disabled
         - info-change-disabled
         - shares-disabled
+        - password-reset-disabled
       description: |
         Options:
           * `publickey-change-disabled` - changing SSH public keys is not allowed
@@ -3760,6 +3919,7 @@ components:
           * `api-key-auth-change-disabled` - enabling/disabling API key authentication is not allowed
           * `info-change-disabled` - changing info such as email and description is not allowed
           * `shares-disabled` - sharing files and directories with external users is disabled
+          * `password-reset-disabled` - resetting the password is disabled
     RetentionCheckNotification:
       type: string
       enum:

+ 80 - 0
httpd/server.go

@@ -23,6 +23,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sdk"
+	"github.com/drakkan/sftpgo/v2/smtp"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/version"
 )
@@ -128,6 +129,9 @@ func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, error string)
 	if s.binding.showAdminLoginURL() {
 		data.AltLoginURL = webLoginPath
 	}
+	if smtp.IsEnabled() {
+		data.ForgotPwdURL = webClientForgotPwdPath
+	}
 	renderClientTemplate(w, templateClientLogin, data)
 }
 
@@ -190,6 +194,43 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 	s.loginUser(w, r, &user, connectionID, ipAddr, false, s.renderClientLoginPage)
 }
 
+func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
+	err := r.ParseForm()
+	if err != nil {
+		renderClientResetPwdPage(w, err.Error())
+		return
+	}
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		renderClientForbiddenPage(w, r, err.Error())
+		return
+	}
+	_, user, err := handleResetPassword(r, r.Form.Get("code"), r.Form.Get("password"), false)
+	if err != nil {
+		if e, ok := err.(*util.ValidationError); ok {
+			renderClientResetPwdPage(w, e.GetErrorString())
+			return
+		}
+		renderClientResetPwdPage(w, err.Error())
+		return
+	}
+	connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String())
+	if err := checkHTTPClientUser(user, r, connectionID); err != nil {
+		renderClientResetPwdPage(w, fmt.Sprintf("Password reset successfully but unable to login: %v", err.Error()))
+		return
+	}
+
+	defer user.CloseFs() //nolint:errcheck
+	err = user.CheckFsRoot(connectionID)
+	if err != nil {
+		logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
+		renderClientResetPwdPage(w, fmt.Sprintf("Password reset successfully but unable to login: %v", err.Error()))
+		return
+	}
+	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
+	s.loginUser(w, r, user, connectionID, ipAddr, false, renderClientResetPwdPage)
+}
+
 func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
 	claims, err := getTokenClaims(r)
@@ -424,6 +465,9 @@ func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, error string)
 	if s.binding.showClientLoginURL() {
 		data.AltLoginURL = webClientLoginPath
 	}
+	if smtp.IsEnabled() {
+		data.ForgotPwdURL = webAdminForgotPwdPath
+	}
 	renderAdminTemplate(w, templateLogin, data)
 }
 
@@ -436,6 +480,30 @@ func (s *httpdServer) handleWebAdminLogin(w http.ResponseWriter, r *http.Request
 	s.renderAdminLoginPage(w, "")
 }
 
+func (s *httpdServer) handleWebAdminPasswordResetPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
+	err := r.ParseForm()
+	if err != nil {
+		renderResetPwdPage(w, err.Error())
+		return
+	}
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		renderForbiddenPage(w, r, err.Error())
+		return
+	}
+	admin, _, err := handleResetPassword(r, r.Form.Get("code"), r.Form.Get("password"), true)
+	if err != nil {
+		if e, ok := err.(*util.ValidationError); ok {
+			renderResetPwdPage(w, e.GetErrorString())
+			return
+		}
+		renderResetPwdPage(w, err.Error())
+		return
+	}
+
+	s.loginAdmin(w, r, admin, false, renderResetPwdPage)
+}
+
 func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
 	if dataprovider.HasAdmin() {
@@ -901,6 +969,10 @@ func (s *httpdServer) initializeRouter() {
 	s.router.Post(sharesPath+"/{id}", uploadToShare)
 
 	s.router.Get(tokenPath, s.getToken)
+	s.router.Post(adminPath+"/{username}/forgot-password", forgotAdminPassword)
+	s.router.Post(adminPath+"/{username}/reset-password", resetAdminPassword)
+	s.router.Post(userPath+"/{username}/forgot-password", forgotUserPassword)
+	s.router.Post(userPath+"/{username}/reset-password", resetUserPassword)
 
 	s.router.Group(func(router chi.Router) {
 		router.Use(checkAPIKeyAuth(s.tokenAuth, dataprovider.APIKeyScopeAdmin))
@@ -1080,6 +1152,10 @@ func (s *httpdServer) initializeRouter() {
 		})
 		s.router.Get(webClientLoginPath, s.handleClientWebLogin)
 		s.router.Post(webClientLoginPath, s.handleWebClientLoginPost)
+		s.router.Get(webClientForgotPwdPath, handleWebClientForgotPwd)
+		s.router.Post(webClientForgotPwdPath, handleWebClientForgotPwdPost)
+		s.router.Get(webClientResetPwdPath, handleWebClientPasswordReset)
+		s.router.Post(webClientResetPwdPath, s.handleWebClientPasswordResetPost)
 		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
 			jwtAuthenticatorPartial(tokenAudienceWebClientPartial)).
 			Get(webClientTwoFactorPath, handleWebClientTwoFactor)
@@ -1160,6 +1236,10 @@ func (s *httpdServer) initializeRouter() {
 		s.router.Post(webLoginPath, s.handleWebAdminLoginPost)
 		s.router.Get(webAdminSetupPath, handleWebAdminSetupGet)
 		s.router.Post(webAdminSetupPath, s.handleWebAdminSetupPost)
+		s.router.Get(webAdminForgotPwdPath, handleWebAdminForgotPwd)
+		s.router.Post(webAdminForgotPwdPath, handleWebAdminForgotPwdPost)
+		s.router.Get(webAdminResetPwdPath, handleWebAdminPasswordReset)
+		s.router.Post(webAdminResetPwdPath, s.handleWebAdminPasswordResetPost)
 		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
 			jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)).
 			Get(webAdminTwoFactorPath, handleWebAdminTwoFactor)

+ 26 - 6
httpd/web.go

@@ -16,17 +16,21 @@ const (
 	redactedSecret            = "[**redacted**]"
 	csrfFormToken             = "_form_token"
 	csrfHeaderToken           = "X-CSRF-TOKEN"
+	templateCommonDir         = "common"
 	templateTwoFactor         = "twofactor.html"
 	templateTwoFactorRecovery = "twofactor-recovery.html"
+	templateForgotPassword    = "forgot-password.html"
+	templateResetPassword     = "reset-password.html"
 )
 
 type loginPage struct {
-	CurrentURL  string
-	Version     string
-	Error       string
-	CSRFToken   string
-	StaticURL   string
-	AltLoginURL string
+	CurrentURL   string
+	Version      string
+	Error        string
+	CSRFToken    string
+	StaticURL    string
+	AltLoginURL  string
+	ForgotPwdURL string
 }
 
 type twoFactorPage struct {
@@ -38,6 +42,22 @@ type twoFactorPage struct {
 	RecoveryURL string
 }
 
+type forgotPwdPage struct {
+	CurrentURL string
+	Error      string
+	CSRFToken  string
+	StaticURL  string
+	Title      string
+}
+
+type resetPwdPage struct {
+	CurrentURL string
+	Error      string
+	CSRFToken  string
+	StaticURL  string
+	Title      string
+}
+
 func getSliceFromDelimitedValues(values, delimiter string) []string {
 	result := []string{}
 	for _, v := range strings.Split(values, delimiter) {

+ 99 - 22
httpd/webadmin.go

@@ -19,6 +19,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sdk"
+	"github.com/drakkan/sftpgo/v2/smtp"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/version"
 	"github.com/drakkan/sftpgo/v2/vfs"
@@ -70,6 +71,8 @@ const (
 	pageChangePwdTitle   = "Change password"
 	pageMaintenanceTitle = "Maintenance"
 	pageDefenderTitle    = "Defender"
+	pageForgotPwdTitle   = "SFTPGo Admin - Forgot password"
+	pageResetPwdTitle    = "SFTPGo Admin - Reset password"
 	pageSetupTitle       = "Create first admin user"
 	defaultQueryLimit    = 500
 )
@@ -250,51 +253,57 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateConnections),
 	}
-	messagePath := []string{
+	messagePaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateMessage),
 	}
-	foldersPath := []string{
+	foldersPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateFolders),
 	}
-	folderPath := []string{
+	folderPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateFsConfig),
 		filepath.Join(templatesPath, templateAdminDir, templateFolder),
 	}
-	statusPath := []string{
+	statusPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateStatus),
 	}
-	loginPath := []string{
+	loginPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBaseLogin),
 		filepath.Join(templatesPath, templateAdminDir, templateLogin),
 	}
-	maintenancePath := []string{
+	maintenancePaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateMaintenance),
 	}
-	defenderPath := []string{
+	defenderPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateDefender),
 	}
-	mfaPath := []string{
+	mfaPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateMFA),
 	}
-	twoFactorPath := []string{
+	twoFactorPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBaseLogin),
 		filepath.Join(templatesPath, templateAdminDir, templateTwoFactor),
 	}
-	twoFactorRecoveryPath := []string{
+	twoFactorRecoveryPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBaseLogin),
 		filepath.Join(templatesPath, templateAdminDir, templateTwoFactorRecovery),
 	}
-	setupPath := []string{
+	setupPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBaseLogin),
 		filepath.Join(templatesPath, templateAdminDir, templateSetup),
 	}
+	forgotPwdPaths := []string{
+		filepath.Join(templatesPath, templateCommonDir, templateForgotPassword),
+	}
+	resetPwdPaths := []string{
+		filepath.Join(templatesPath, templateCommonDir, templateResetPassword),
+	}
 
 	fsBaseTpl := template.New("fsBaseTemplate").Funcs(template.FuncMap{
 		"ListFSProviders": sdk.ListProviders,
@@ -304,19 +313,21 @@ func loadAdminTemplates(templatesPath string) {
 	adminsTmpl := util.LoadTemplate(nil, adminsPaths...)
 	adminTmpl := util.LoadTemplate(nil, adminPaths...)
 	connectionsTmpl := util.LoadTemplate(nil, connectionsPaths...)
-	messageTmpl := util.LoadTemplate(nil, messagePath...)
-	foldersTmpl := util.LoadTemplate(nil, foldersPath...)
-	folderTmpl := util.LoadTemplate(fsBaseTpl, folderPath...)
-	statusTmpl := util.LoadTemplate(nil, statusPath...)
-	loginTmpl := util.LoadTemplate(nil, loginPath...)
+	messageTmpl := util.LoadTemplate(nil, messagePaths...)
+	foldersTmpl := util.LoadTemplate(nil, foldersPaths...)
+	folderTmpl := util.LoadTemplate(fsBaseTpl, folderPaths...)
+	statusTmpl := util.LoadTemplate(nil, statusPaths...)
+	loginTmpl := util.LoadTemplate(nil, loginPaths...)
 	profileTmpl := util.LoadTemplate(nil, profilePaths...)
 	changePwdTmpl := util.LoadTemplate(nil, changePwdPaths...)
-	maintenanceTmpl := util.LoadTemplate(nil, maintenancePath...)
-	defenderTmpl := util.LoadTemplate(nil, defenderPath...)
-	mfaTmpl := util.LoadTemplate(nil, mfaPath...)
-	twoFactorTmpl := util.LoadTemplate(nil, twoFactorPath...)
-	twoFactorRecoveryTmpl := util.LoadTemplate(nil, twoFactorRecoveryPath...)
-	setupTmpl := util.LoadTemplate(nil, setupPath...)
+	maintenanceTmpl := util.LoadTemplate(nil, maintenancePaths...)
+	defenderTmpl := util.LoadTemplate(nil, defenderPaths...)
+	mfaTmpl := util.LoadTemplate(nil, mfaPaths...)
+	twoFactorTmpl := util.LoadTemplate(nil, twoFactorPaths...)
+	twoFactorRecoveryTmpl := util.LoadTemplate(nil, twoFactorRecoveryPaths...)
+	setupTmpl := util.LoadTemplate(nil, setupPaths...)
+	forgotPwdTmpl := util.LoadTemplate(nil, forgotPwdPaths...)
+	resetPwdTmpl := util.LoadTemplate(nil, resetPwdPaths...)
 
 	adminTemplates[templateUsers] = usersTmpl
 	adminTemplates[templateUser] = userTmpl
@@ -336,6 +347,8 @@ func loadAdminTemplates(templatesPath string) {
 	adminTemplates[templateTwoFactor] = twoFactorTmpl
 	adminTemplates[templateTwoFactorRecovery] = twoFactorRecoveryTmpl
 	adminTemplates[templateSetup] = setupTmpl
+	adminTemplates[templateForgotPassword] = forgotPwdTmpl
+	adminTemplates[templateResetPassword] = resetPwdTmpl
 }
 
 func getBasePageData(title, currentURL string, r *http.Request) basePage {
@@ -419,6 +432,28 @@ func renderNotFoundPage(w http.ResponseWriter, r *http.Request, err error) {
 	renderMessagePage(w, r, page404Title, page404Body, http.StatusNotFound, err, "")
 }
 
+func renderForgotPwdPage(w http.ResponseWriter, error string) {
+	data := forgotPwdPage{
+		CurrentURL: webAdminForgotPwdPath,
+		Error:      error,
+		CSRFToken:  createCSRFToken(),
+		StaticURL:  webStaticFilesPath,
+		Title:      pageForgotPwdTitle,
+	}
+	renderAdminTemplate(w, templateForgotPassword, data)
+}
+
+func renderResetPwdPage(w http.ResponseWriter, error string) {
+	data := resetPwdPage{
+		CurrentURL: webAdminResetPwdPath,
+		Error:      error,
+		CSRFToken:  createCSRFToken(),
+		StaticURL:  webStaticFilesPath,
+		Title:      pageResetPwdTitle,
+	}
+	renderAdminTemplate(w, templateResetPassword, data)
+}
+
 func renderTwoFactorPage(w http.ResponseWriter, error string) {
 	data := twoFactorPage{
 		CurrentURL:  webAdminTwoFactorPath,
@@ -1135,6 +1170,48 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 	return user, err
 }
 
+func handleWebAdminForgotPwd(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	if !smtp.IsEnabled() {
+		renderNotFoundPage(w, r, errors.New("this page does not exist"))
+		return
+	}
+	renderForgotPwdPage(w, "")
+}
+
+func handleWebAdminForgotPwdPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	err := r.ParseForm()
+	if err != nil {
+		renderForgotPwdPage(w, err.Error())
+		return
+	}
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		renderForbiddenPage(w, r, err.Error())
+		return
+	}
+	username := r.Form.Get("username")
+	err = handleForgotPassword(r, username, true)
+	if err != nil {
+		if e, ok := err.(*util.ValidationError); ok {
+			renderForgotPwdPage(w, e.GetErrorString())
+			return
+		}
+		renderForgotPwdPage(w, err.Error())
+		return
+	}
+	http.Redirect(w, r, webAdminResetPwdPath, http.StatusFound)
+}
+
+func handleWebAdminPasswordReset(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
+	if !smtp.IsEnabled() {
+		renderNotFoundPage(w, r, errors.New("this page does not exist"))
+		return
+	}
+	renderResetPwdPage(w, "")
+}
+
 func handleWebAdminTwoFactor(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	renderTwoFactorPage(w, "")

+ 78 - 0
httpd/webclient.go

@@ -3,6 +3,7 @@ package httpd
 import (
 	"bytes"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"html/template"
 	"io"
@@ -22,6 +23,7 @@ import (
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/mfa"
 	"github.com/drakkan/sftpgo/v2/sdk"
+	"github.com/drakkan/sftpgo/v2/smtp"
 	"github.com/drakkan/sftpgo/v2/util"
 	"github.com/drakkan/sftpgo/v2/version"
 	"github.com/drakkan/sftpgo/v2/vfs"
@@ -48,6 +50,8 @@ const (
 	pageClientChangePwdTitle        = "Change password"
 	pageClient2FATitle              = "Two-factor auth"
 	pageClientEditFileTitle         = "Edit file"
+	pageClientForgotPwdTitle        = "SFTPGo WebClient - Forgot password"
+	pageClientResetPwdTitle         = "SFTPGo WebClient - Reset password"
 )
 
 // condResult is the result of an HTTP request precondition check.
@@ -219,6 +223,12 @@ func loadClientTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateClientDir, templateClientBaseLogin),
 		filepath.Join(templatesPath, templateClientDir, templateClientTwoFactorRecovery),
 	}
+	forgotPwdPaths := []string{
+		filepath.Join(templatesPath, templateCommonDir, templateForgotPassword),
+	}
+	resetPwdPaths := []string{
+		filepath.Join(templatesPath, templateCommonDir, templateResetPassword),
+	}
 
 	filesTmpl := util.LoadTemplate(nil, filesPaths...)
 	profileTmpl := util.LoadTemplate(nil, profilePaths...)
@@ -231,6 +241,8 @@ func loadClientTemplates(templatesPath string) {
 	editFileTmpl := util.LoadTemplate(nil, editFilePath...)
 	sharesTmpl := util.LoadTemplate(nil, sharesPaths...)
 	shareTmpl := util.LoadTemplate(nil, sharePaths...)
+	forgotPwdTmpl := util.LoadTemplate(nil, forgotPwdPaths...)
+	resetPwdTmpl := util.LoadTemplate(nil, resetPwdPaths...)
 
 	clientTemplates[templateClientFiles] = filesTmpl
 	clientTemplates[templateClientProfile] = profileTmpl
@@ -243,6 +255,8 @@ func loadClientTemplates(templatesPath string) {
 	clientTemplates[templateClientEditFile] = editFileTmpl
 	clientTemplates[templateClientShares] = sharesTmpl
 	clientTemplates[templateClientShare] = shareTmpl
+	clientTemplates[templateForgotPassword] = forgotPwdTmpl
+	clientTemplates[templateResetPassword] = resetPwdTmpl
 }
 
 func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage {
@@ -273,6 +287,28 @@ func getBaseClientPageData(title, currentURL string, r *http.Request) baseClient
 	}
 }
 
+func renderClientForgotPwdPage(w http.ResponseWriter, error string) {
+	data := forgotPwdPage{
+		CurrentURL: webClientForgotPwdPath,
+		Error:      error,
+		CSRFToken:  createCSRFToken(),
+		StaticURL:  webStaticFilesPath,
+		Title:      pageClientForgotPwdTitle,
+	}
+	renderClientTemplate(w, templateForgotPassword, data)
+}
+
+func renderClientResetPwdPage(w http.ResponseWriter, error string) {
+	data := resetPwdPage{
+		CurrentURL: webClientResetPwdPath,
+		Error:      error,
+		CSRFToken:  createCSRFToken(),
+		StaticURL:  webStaticFilesPath,
+		Title:      pageClientResetPwdTitle,
+	}
+	renderClientTemplate(w, templateResetPassword, data)
+}
+
 func renderClientTemplate(w http.ResponseWriter, tmplName string, data interface{}) {
 	err := clientTemplates[tmplName].ExecuteTemplate(w, tmplName, data)
 	if err != nil {
@@ -957,3 +993,45 @@ func getShareFromPostFields(r *http.Request) (*dataprovider.Share, error) {
 	share.ExpiresAt = expirationDateMillis
 	return share, nil
 }
+
+func handleWebClientForgotPwd(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	if !smtp.IsEnabled() {
+		renderClientNotFoundPage(w, r, errors.New("this page does not exist"))
+		return
+	}
+	renderClientForgotPwdPage(w, "")
+}
+
+func handleWebClientForgotPwdPost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	err := r.ParseForm()
+	if err != nil {
+		renderClientForgotPwdPage(w, err.Error())
+		return
+	}
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		renderClientForbiddenPage(w, r, err.Error())
+		return
+	}
+	username := r.Form.Get("username")
+	err = handleForgotPassword(r, username, false)
+	if err != nil {
+		if e, ok := err.(*util.ValidationError); ok {
+			renderClientForgotPwdPage(w, e.GetErrorString())
+			return
+		}
+		renderClientForgotPwdPage(w, err.Error())
+		return
+	}
+	http.Redirect(w, r, webClientResetPwdPath, http.StatusFound)
+}
+
+func handleWebClientPasswordReset(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
+	if !smtp.IsEnabled() {
+		renderClientNotFoundPage(w, r, errors.New("this page does not exist"))
+		return
+	}
+	renderClientResetPwdPage(w, "")
+}

+ 4 - 2
sdk/user.go

@@ -16,12 +16,14 @@ const (
 	WebClientAPIKeyAuthChangeDisabled = "api-key-auth-change-disabled"
 	WebClientInfoChangeDisabled       = "info-change-disabled"
 	WebClientSharesDisabled           = "shares-disabled"
+	WebClientPasswordResetDisabled    = "password-reset-disabled"
 )
 
 var (
 	// WebClientOptions defines the available options for the web client interface/user REST API
-	WebClientOptions = []string{WebClientWriteDisabled, WebClientPasswordChangeDisabled, WebClientPubKeyChangeDisabled,
-		WebClientMFADisabled, WebClientAPIKeyAuthChangeDisabled, WebClientInfoChangeDisabled, WebClientSharesDisabled}
+	WebClientOptions = []string{WebClientWriteDisabled, WebClientPasswordChangeDisabled, WebClientPasswordResetDisabled,
+		WebClientPubKeyChangeDisabled, WebClientMFADisabled, WebClientAPIKeyAuthChangeDisabled, WebClientInfoChangeDisabled,
+		WebClientSharesDisabled}
 	// UserTypes defines the supported user type hints for auth plugins
 	UserTypes = []string{string(UserTypeLDAP), string(UserTypeOS)}
 )

+ 13 - 0
smtp/smtp.go

@@ -31,6 +31,7 @@ const (
 const (
 	templateEmailDir             = "email"
 	templateRetentionCheckResult = "retention-check-report.html"
+	templatePasswordReset        = "reset-password.html"
 )
 
 var (
@@ -146,7 +147,12 @@ func loadTemplates(templatesPath string) {
 	logger.Debug(logSender, "", "loading templates from %#v", templatesPath)
 	retentionCheckPath := filepath.Join(templatesPath, templateRetentionCheckResult)
 	retentionTmpl := util.LoadTemplate(nil, retentionCheckPath)
+
+	passwordResetPath := filepath.Join(templatesPath, templatePasswordReset)
+	pwdResetTmpl := util.LoadTemplate(nil, passwordResetPath)
+
 	emailTemplates[templateRetentionCheckResult] = retentionTmpl
+	emailTemplates[templatePasswordReset] = pwdResetTmpl
 }
 
 // RenderRetentionReportTemplate executes the retention report template
@@ -157,6 +163,13 @@ func RenderRetentionReportTemplate(buf *bytes.Buffer, data interface{}) error {
 	return emailTemplates[templateRetentionCheckResult].Execute(buf, data)
 }
 
+func RenderPasswordResetTemplate(buf *bytes.Buffer, data interface{}) error {
+	if smtpServer == nil {
+		return errors.New("smtp: not configured")
+	}
+	return emailTemplates[templatePasswordReset].Execute(buf, data)
+}
+
 // SendEmail tries to send an email using the specified parameters.
 func SendEmail(to, subject, body string, contentType EmailContentType) error {
 	if smtpServer == nil {

+ 131 - 0
templates/common/forgot-password.html

@@ -0,0 +1,131 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta name="description" content="">
+    <meta name="author" content="">
+
+    <title>{{.Title}}</title>
+
+    <link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
+
+    <!-- Custom styles for this template-->
+    <link href="{{.StaticURL}}/css/sb-admin-2.min.css" rel="stylesheet">
+    <style>
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Bold-webfont.woff');
+            font-weight: 700;
+            font-style: normal;
+        }
+
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Regular-webfont.woff');
+            font-weight: 400;
+            font-style: normal;
+        }
+
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Light-webfont.woff');
+            font-weight: 300;
+            font-style: normal;
+        }
+
+        div.dt-buttons {
+            margin-bottom: 1em;
+        }
+
+        .text-form-error {
+            color: var(--red) !important;
+        }
+
+        div.dt-buttons {
+            margin-bottom: 1em;
+        }
+
+        .text-form-error {
+            color: var(--red) !important;
+        }
+
+        form.user-custom .custom-checkbox.small label {
+            line-height: 1.5rem;
+        }
+
+        form.user-custom .form-control-user-custom {
+            font-size: 0.9rem;
+            border-radius: 10rem;
+            padding: 1.5rem 1rem;
+        }
+
+        form.user-custom .btn-user-custom {
+            font-size: 0.9rem;
+            border-radius: 10rem;
+            padding: 0.75rem 1rem;
+        }
+    </style>
+
+</head>
+
+<body class="bg-gradient-primary">
+
+    <div class="container">
+
+        <!-- Outer Row -->
+        <div class="row justify-content-center">
+
+            <div class="col-xl-6 col-lg-7 col-md-9">
+
+                <div class="card o-hidden border-0 shadow-lg my-5">
+                    <div class="card-body p-0">
+                        <!-- Nested Row within Card Body -->
+                        <div class="row">
+                            <div class="col-lg-12">
+                                <div class="p-5">
+                                    <div class="text-center">
+                                        <h1 class="h4 text-gray-900 mb-4">Forgot Your Password?</h1>
+                                        <p class="mb-4">If you have added an email address to your account, we'll email you a code to reset your password. Enter your account username below</p>
+                                    </div>
+                                    {{if .Error}}
+                                    <div class="card mb-4 border-left-warning">
+                                        <div class="card-body text-form-error">{{.Error}}</div>
+                                    </div>
+                                    {{end}}
+                                    <form id="forgot_password_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
+                                        class="user-custom">
+                                        <div class="form-group">
+                                            <input type="text" class="form-control form-control-user-custom"
+                                                id="inputUsername" name="username" placeholder="Your username" required>
+                                        </div>
+                                        <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                                        <button type="submit" class="btn btn-primary btn-user-custom btn-block">
+                                            Send Reset Code
+                                        </button>
+                                    </form>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Bootstrap core JavaScript-->
+    <script src="{{.StaticURL}}/vendor/jquery/jquery.min.js"></script>
+    <script src="{{.StaticURL}}/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
+
+    <!-- Core plugin JavaScript-->
+    <script src="{{.StaticURL}}/vendor/jquery-easing/jquery.easing.min.js"></script>
+
+    <!-- Custom scripts for all pages-->
+    <script src="{{.StaticURL}}/js/sb-admin-2.min.js"></script>
+
+</body>
+
+</html>

+ 135 - 0
templates/common/reset-password.html

@@ -0,0 +1,135 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta name="description" content="">
+    <meta name="author" content="">
+
+    <title>{{.Title}}</title>
+
+    <link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
+
+    <!-- Custom styles for this template-->
+    <link href="{{.StaticURL}}/css/sb-admin-2.min.css" rel="stylesheet">
+    <style>
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Bold-webfont.woff');
+            font-weight: 700;
+            font-style: normal;
+        }
+
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Regular-webfont.woff');
+            font-weight: 400;
+            font-style: normal;
+        }
+
+        @font-face {
+            font-family: 'Roboto';
+            src: url('{{.StaticURL}}/vendor/fonts/Roboto-Light-webfont.woff');
+            font-weight: 300;
+            font-style: normal;
+        }
+
+        div.dt-buttons {
+            margin-bottom: 1em;
+        }
+
+        .text-form-error {
+            color: var(--red) !important;
+        }
+
+        div.dt-buttons {
+            margin-bottom: 1em;
+        }
+
+        .text-form-error {
+            color: var(--red) !important;
+        }
+
+        form.user-custom .custom-checkbox.small label {
+            line-height: 1.5rem;
+        }
+
+        form.user-custom .form-control-user-custom {
+            font-size: 0.9rem;
+            border-radius: 10rem;
+            padding: 1.5rem 1rem;
+        }
+
+        form.user-custom .btn-user-custom {
+            font-size: 0.9rem;
+            border-radius: 10rem;
+            padding: 0.75rem 1rem;
+        }
+    </style>
+
+</head>
+
+<body class="bg-gradient-primary">
+
+    <div class="container">
+
+        <!-- Outer Row -->
+        <div class="row justify-content-center">
+
+            <div class="col-xl-6 col-lg-7 col-md-9">
+
+                <div class="card o-hidden border-0 shadow-lg my-5">
+                    <div class="card-body p-0">
+                        <!-- Nested Row within Card Body -->
+                        <div class="row">
+                            <div class="col-lg-12">
+                                <div class="p-5">
+                                    <div class="text-center">
+                                        <h1 class="h4 text-gray-900 mb-4">Reset Password</h1>
+                                        <p class="mb-4">Check your email for the confirmation code</p>
+                                    </div>
+                                    {{if .Error}}
+                                    <div class="card mb-4 border-left-warning">
+                                        <div class="card-body text-form-error">{{.Error}}</div>
+                                    </div>
+                                    {{end}}
+                                    <form id="forgot_password_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
+                                        class="user-custom">
+                                        <div class="form-group">
+                                            <input type="text" class="form-control form-control-user-custom"
+                                                id="inputCode" name="code" placeholder="Confirmation code" required>
+                                        </div>
+                                        <div class="form-group">
+                                            <input type="password" class="form-control form-control-user-custom"
+                                                id="inputPassword" name="password" placeholder="New Password" required>
+                                        </div>
+                                        <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                                        <button type="submit" class="btn btn-primary btn-user-custom btn-block">
+                                            Update Password & Login
+                                        </button>
+                                    </form>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Bootstrap core JavaScript-->
+    <script src="{{.StaticURL}}/vendor/jquery/jquery.min.js"></script>
+    <script src="{{.StaticURL}}/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
+
+    <!-- Core plugin JavaScript-->
+    <script src="{{.StaticURL}}/vendor/jquery-easing/jquery.easing.min.js"></script>
+
+    <!-- Custom scripts for all pages-->
+    <script src="{{.StaticURL}}/js/sb-admin-2.min.js"></script>
+
+</body>
+
+</html>

+ 4 - 0
templates/email/reset-password.html

@@ -0,0 +1,4 @@
+Hello there!
+<br>
+<p>Your SFTPGo email verification code is "{{.Code}}", this code is valid for 10 minutes.</p>
+<p>Please enter this code in SFTPGo to confirm your email address.</p>

+ 5 - 0
templates/webadmin/login.html

@@ -20,6 +20,11 @@
                                         <div class="form-group">
                                             <input type="password" class="form-control form-control-user-custom"
                                                 id="inputPassword" name="password" placeholder="Password" required>
+                                            {{if .ForgotPwdURL}}
+                                            <div class="text-right">
+                                                <a class="small" href="{{.ForgotPwdURL}}">Forgot password?</a>
+                                            </div>
+                                            {{end}}
                                         </div>
                                         <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
                                         <button type="submit" class="btn btn-primary btn-user-custom btn-block">

+ 5 - 0
templates/webclient/login.html

@@ -17,6 +17,11 @@
                                         <div class="form-group">
                                             <input type="password" class="form-control form-control-user-custom"
                                                 id="inputPassword" name="password" placeholder="Password" required>
+                                            {{if .ForgotPwdURL}}
+                                            <div class="text-right">
+                                                <a class="small" href="{{.ForgotPwdURL}}">Forgot password?</a>
+                                            </div>
+                                            {{end}}
                                         </div>
                                         <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
                                         <button type="submit" class="btn btn-primary btn-user-custom btn-block">

+ 22 - 1
util/errors.go

@@ -12,6 +12,11 @@ func (e *ValidationError) Error() string {
 	return fmt.Sprintf("Validation error: %s", e.err)
 }
 
+// GetErrorString returns the unmodified error string
+func (e *ValidationError) GetErrorString() string {
+	return e.err
+}
+
 // NewValidationError returns a validation errors
 func NewValidationError(error string) *ValidationError {
 	return &ValidationError{
@@ -19,7 +24,7 @@ func NewValidationError(error string) *ValidationError {
 	}
 }
 
-// RecordNotFoundError raised if a requested user is not found
+// RecordNotFoundError raised if a requested object is not found
 type RecordNotFoundError struct {
 	err string
 }
@@ -53,3 +58,19 @@ func NewMethodDisabledError(error string) *MethodDisabledError {
 		err: error,
 	}
 }
+
+// GenericError raised for not well categorized error
+type GenericError struct {
+	err string
+}
+
+func (e *GenericError) Error() string {
+	return e.err
+}
+
+// NewGenericError returns a generic error
+func NewGenericError(error string) *GenericError {
+	return &GenericError{
+		err: error,
+	}
+}