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:
Nicola Murino 2021-11-13 13:25:43 +01:00
parent b331dc5686
commit 78233ff9a3
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
25 changed files with 1787 additions and 60 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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
View file

@ -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
View file

@ -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=

View file

@ -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)

View file

@ -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 {

View file

@ -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
}

View file

@ -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()
}
}
}()

View file

@ -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,

View file

@ -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
View 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
})
}

View file

@ -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:

View file

@ -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)

View file

@ -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) {

View file

@ -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, "")

View file

@ -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, "")
}

View file

@ -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)}
)

View file

@ -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 {

View 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>

View 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>

View 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>

View file

@ -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">

View file

@ -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">

View file

@ -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,
}
}