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
This commit is contained in:
parent
b331dc5686
commit
78233ff9a3
25 changed files with 1787 additions and 60 deletions
|
@ -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.
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
18
go.mod
18
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
|
||||
)
|
||||
|
|
35
go.sum
35
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=
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
httpd/resetcode.go
Normal file
43
httpd/resetcode.go
Normal file
|
@ -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
|
||||
})
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
32
httpd/web.go
32
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) {
|
||||
|
|
|
@ -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, "")
|
||||
|
|
|
@ -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, "")
|
||||
}
|
||||
|
|
|
@ -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
smtp/smtp.go
13
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
templates/common/forgot-password.html
Normal file
131
templates/common/forgot-password.html
Normal file
|
@ -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
templates/common/reset-password.html
Normal file
135
templates/common/reset-password.html
Normal file
|
@ -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
templates/email/reset-password.html
Normal file
4
templates/email/reset-password.html
Normal file
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue