diff --git a/README.md b/README.md index b48f8aa4..97650141 100644 --- a/README.md +++ b/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. diff --git a/dataprovider/user.go b/dataprovider/user.go index 46479f5b..771cda82 100644 --- a/dataprovider/user.go +++ b/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) diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md index 3609c0dc..2b077fa4 100644 --- a/docs/rate-limiting.md +++ b/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 diff --git a/go.mod b/go.mod index 599f9199..7e2d8a91 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 9e89dcf2..f37dd0aa 100644 --- a/go.sum +++ b/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= diff --git a/httpd/api_admin.go b/httpd/api_admin.go index d5b59f85..718feef6 100644 --- a/httpd/api_admin.go +++ b/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) diff --git a/httpd/api_user.go b/httpd/api_user.go index 1a165a0b..fdaa5c6e 100644 --- a/httpd/api_user.go +++ b/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 { diff --git a/httpd/api_utils.go b/httpd/api_utils.go index abfe0ad9..86ef8fd2 100644 --- a/httpd/api_utils.go +++ b/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 +} diff --git a/httpd/httpd.go b/httpd/httpd.go index 0354d171..6465363b 100644 --- a/httpd/httpd.go +++ b/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() } } }() diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 189975c5..0957c23e 100644 --- a/httpd/httpd_test.go +++ b/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, diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 14221fb8..ddc8dcf8 100644 --- a/httpd/internal_test.go +++ b/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)) +} diff --git a/httpd/resetcode.go b/httpd/resetcode.go new file mode 100644 index 00000000..a94846e9 --- /dev/null +++ b/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 + }) +} diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 80524257..2551f7e7 100644 --- a/httpd/schema/openapi.yaml +++ b/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: diff --git a/httpd/server.go b/httpd/server.go index 14e53a9e..a90ac685 100644 --- a/httpd/server.go +++ b/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) diff --git a/httpd/web.go b/httpd/web.go index ed67a73e..9d3850d9 100644 --- a/httpd/web.go +++ b/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) { diff --git a/httpd/webadmin.go b/httpd/webadmin.go index 69938f15..10fc083b 100644 --- a/httpd/webadmin.go +++ b/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, "") diff --git a/httpd/webclient.go b/httpd/webclient.go index 74e2e5c7..4180ea10 100644 --- a/httpd/webclient.go +++ b/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, "") +} diff --git a/sdk/user.go b/sdk/user.go index 2d201f62..4b40e3ce 100644 --- a/sdk/user.go +++ b/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)} ) diff --git a/smtp/smtp.go b/smtp/smtp.go index c22b0574..2a6d8c45 100644 --- a/smtp/smtp.go +++ b/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 { diff --git a/templates/common/forgot-password.html b/templates/common/forgot-password.html new file mode 100644 index 00000000..4ef96f23 --- /dev/null +++ b/templates/common/forgot-password.html @@ -0,0 +1,131 @@ + + + +
+ + + + + + + +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
+Check your email for the confirmation code
+Your SFTPGo email verification code is "{{.Code}}", this code is valid for 10 minutes.
+Please enter this code in SFTPGo to confirm your email address.
diff --git a/templates/webadmin/login.html b/templates/webadmin/login.html index 751466dd..3d19ea2c 100644 --- a/templates/webadmin/login.html +++ b/templates/webadmin/login.html @@ -20,6 +20,11 @@