add XOAUTH2

start the countdown, let's see how long it takes for your favorite
Go-based proprietary SFTP server to notice this change, copy the SFTPGo
code and thus violate its license, and announce the same feature :)

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2023-06-03 16:17:32 +02:00
parent 8339fee69d
commit 48939b2b4f
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
18 changed files with 1329 additions and 115 deletions

View file

@ -453,11 +453,17 @@ The configuration file contains the following sections:
- `from`, string. From address, for example `SFTPGo <sftpgo@example.com>`. Many SMTP servers reject emails without a `From` header so, if not set, SFTPGo will try to use the username as fallback, this may or may not be appropriate. Default: blank
- `user`, string. SMTP username. Default: blank
- `password`, string. SMTP password. Leaving both username and password empty the SMTP authentication will be disabled. Default: blank
- `auth_type`, integer. 0 means `Plain`, 1 means `Login`, 2 means `CRAM-MD5`. Default: `0`.
- `auth_type`, integer. 0 means `Plain`, 1 means `Login`, 2 means `CRAM-MD5`, 3 means `XOAUTH2`. Default: `0`.
- `encryption`, integer. 0 means no encryption, 1 means `TLS`, 2 means `STARTTLS`. Default: `0`.
- `domain`, string. Domain to use for `HELO` command, if empty `localhost` will be used. Default: blank.
- `templates_path`, string. Path to the email templates. This can be an absolute path or a path relative to the config dir. Templates are searched within a subdirectory named "email" in the specified path. You can customize the email templates by simply specifying an alternate path and putting your custom templates there.
- `debug`, integer. Set to `1` to enable SMTP debug. Default: `0`.
- `oauth2`, struct containing OAuth2 related configurations:
- `provider`, integer, 0 means `Google`, 1 means `Microsoft`. Default: `0`.
- `tenant`, string. Azure Active Directory tenant for the Microsoft provider. Typical values are `common`, `organizations`, `consumers` or tenant identifier. If empty `common` is used. Default: blank.
- `client_id`, string. Default: blank.
- `client_secret`, string. Default: blank.
- `refresh_token`, string. Default: blank.
</details>
<details><summary><font size=4>Plugins</font></summary>

36
go.mod
View file

@ -19,7 +19,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.19.8
github.com/aws/aws-sdk-go-v2/service/sts v1.19.0
github.com/bmatcuk/doublestar/v4 v4.6.0
github.com/cockroachdb/cockroach-go/v2 v2.3.3
github.com/cockroachdb/cockroach-go/v2 v2.3.4
github.com/coreos/go-oidc/v3 v3.6.0
github.com/drakkan/webdav v0.0.0-20230227175313-32996838bcd8
github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
@ -34,14 +34,14 @@ require (
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/google/uuid v1.3.0
github.com/hashicorp/go-hclog v1.5.0
github.com/hashicorp/go-plugin v1.4.10-0.20230403150917-e889c1ba1044
github.com/hashicorp/go-plugin v1.4.10
github.com/hashicorp/go-retryablehttp v0.7.2
github.com/jackc/pgx/v5 v5.3.2-0.20230520135323-70a200cff4d4
github.com/jackc/pgx/v5 v5.3.2-0.20230603125928-d9560c78b8e6
github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
github.com/klauspost/compress v1.16.5
github.com/lestrrat-go/jwx/v2 v2.0.9
github.com/lithammer/shortuuid/v3 v3.0.7
github.com/mattn/go-sqlite3 v1.14.16
github.com/mattn/go-sqlite3 v1.14.17
github.com/mhale/smtpd v0.8.0
github.com/minio/sio v0.3.1
github.com/otiai10/copy v1.11.0
@ -54,16 +54,16 @@ require (
github.com/rs/xid v1.5.0
github.com/rs/zerolog v1.29.1
github.com/sftpgo/sdk v0.1.5-0.20230524172149-afb96ebee860
github.com/shirou/gopsutil/v3 v3.23.4
github.com/shirou/gopsutil/v3 v3.23.5
github.com/spf13/afero v1.9.5
github.com/spf13/cobra v1.7.0
github.com/spf13/viper v1.15.0
github.com/stretchr/testify v1.8.3
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.8.4
github.com/studio-b12/gowebdav v0.0.0-20230203202212-3282f94193f2
github.com/subosito/gotenv v1.4.2
github.com/unrolled/secure v1.13.0
github.com/wagslane/go-password-validator v0.3.0
github.com/wneessen/go-mail v0.3.9
github.com/wneessen/go-mail v0.3.10-0.20230531074101-4100fef083df
github.com/yl2chen/cidranger v1.0.3-0.20210928021809-d1cb2c52f37a
go.etcd.io/bbolt v1.3.7
go.uber.org/automaxprocs v1.5.2
@ -74,15 +74,15 @@ require (
golang.org/x/sys v0.8.0
golang.org/x/term v0.8.0
golang.org/x/time v0.3.0
google.golang.org/api v0.124.0
google.golang.org/api v0.125.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
cloud.google.com/go v0.110.2 // indirect
cloud.google.com/go/compute v1.19.3 // indirect
cloud.google.com/go/compute v1.20.0 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/iam v1.0.1 // indirect
cloud.google.com/go/iam v1.1.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect
github.com/ajg/form v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
@ -115,7 +115,7 @@ require (
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/s2a-go v0.1.4 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
github.com/googleapis/gax-go/v2 v2.9.1 // indirect
github.com/googleapis/gax-go/v2 v2.10.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
@ -123,7 +123,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
@ -152,17 +152,17 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tklauser/go-sysconf v0.3.11 // indirect
github.com/tklauser/numcpus v0.6.0 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/tools v0.9.1 // indirect
golang.org/x/tools v0.9.3 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230526203410-71b5a4ffd15e // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e // indirect
google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect
google.golang.org/grpc v1.55.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

74
go.sum
View file

@ -124,8 +124,8 @@ cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARy
cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo=
cloud.google.com/go/compute v1.15.1/go.mod h1:bjjoF/NtFUrkD/urWfdHaKuOPDR5nWIs63rR+SXhcpA=
cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs=
cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds=
cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI=
cloud.google.com/go/compute v1.20.0 h1:cUOcywWuowO9It2i1KX1lIb0HH7gLv6nENKuZGnlcSo=
cloud.google.com/go/compute v1.20.0/go.mod h1:kn5BhC++qUWR/AM3Dn21myV7QbgqejW04cAOrtppaQI=
cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU=
cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM=
@ -218,8 +218,8 @@ cloud.google.com/go/iam v0.6.0/go.mod h1:+1AH33ueBne5MzYccyMHtEKqLE4/kJOibtffMHD
cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQEYOeg=
cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE=
cloud.google.com/go/iam v0.10.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU=
cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8=
cloud.google.com/go/iam v1.1.0 h1:67gSqaPukx7O8WLLHMa0PNs3EBGd2eE4d+psbO/CO94=
cloud.google.com/go/iam v1.1.0/go.mod h1:nxdHjaKfCr7fNYx/HJMM8LgiMugmveWlkatear5gVyk=
cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc=
cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A=
cloud.google.com/go/ids v1.1.0/go.mod h1:WIuwCaYVOzHIj2OhN9HAwvW+DBdmUAdcWlFxRl+KubM=
@ -697,8 +697,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/cockroach-go/v2 v2.3.3 h1:fNmtG6XhoA1DhdDCIu66YyGSsNb1szj4CaAsbDxRmy4=
github.com/cockroachdb/cockroach-go/v2 v2.3.3/go.mod h1:1wNJ45eSXW9AnOc3skntW9ZUZz6gxrQK3cOj3rK+BC8=
github.com/cockroachdb/cockroach-go/v2 v2.3.4 h1:dm6K7p7VOldWbgUllY4D/1Qtqv/D0UKm6OLhpF53aJU=
github.com/cockroachdb/cockroach-go/v2 v2.3.4/go.mod h1:1wNJ45eSXW9AnOc3skntW9ZUZz6gxrQK3cOj3rK+BC8=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo=
github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA=
@ -1227,8 +1227,8 @@ github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK
github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo=
github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY=
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
github.com/googleapis/gax-go/v2 v2.9.1 h1:DpTpJqzZ3NvX9zqjhIuI1oVzYZMvboZe+3LoeEIJjHM=
github.com/googleapis/gax-go/v2 v2.9.1/go.mod h1:4FG3gMrVZlyMp5itSYKMU9z/lBE7+SbnUOvzH2HqbEY=
github.com/googleapis/gax-go/v2 v2.10.0 h1:ebSgKfMxynOdxw8QQuFOKMgomqeLGPqNLQox2bo42zg=
github.com/googleapis/gax-go/v2 v2.10.0/go.mod h1:4UOEnMCrxsSqQ940WnTiD6qJ63le2ev3xfyagutxiPw=
github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU=
github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
@ -1294,8 +1294,8 @@ github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.4.10-0.20230403150917-e889c1ba1044 h1:dEFpX4X++vjyeh0mqp0rGbTF2/gXfSc8bOKSTrh0ucg=
github.com/hashicorp/go-plugin v1.4.10-0.20230403150917-e889c1ba1044/go.mod h1:6/1TEzT0eQznvI/gV2CM29DLSkAK/e58mUWKVsPaph0=
github.com/hashicorp/go-plugin v1.4.10 h1:xUbmA4jC6Dq163/fWcp8P3JuHilrHHMLNRxzGQJ9hNk=
github.com/hashicorp/go-plugin v1.4.10/go.mod h1:6/1TEzT0eQznvI/gV2CM29DLSkAK/e58mUWKVsPaph0=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0=
@ -1393,8 +1393,8 @@ github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
github.com/jackc/pgx/v5 v5.3.2-0.20230520135323-70a200cff4d4 h1:ZNj2ThNr8qk34TWl+5uBDC01fa+bPynz2a8ju5nhvwU=
github.com/jackc/pgx/v5 v5.3.2-0.20230520135323-70a200cff4d4/go.mod h1:sU+RaYl9qnhD3Ce+mwnFii6YEPx70mCYghBzKvqq4qo=
github.com/jackc/pgx/v5 v5.3.2-0.20230603125928-d9560c78b8e6 h1:XSDMgUsVBRwSSqRvsIOh78HavVE1WNgkIhZXLhtkKxs=
github.com/jackc/pgx/v5 v5.3.2-0.20230603125928-d9560c78b8e6/go.mod h1:q6iHT8uDNXWiFNOlRqJzBTaSH3+2xCXkokxHZC5qWFY=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
@ -1446,8 +1446,9 @@ github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e
github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@ -1540,8 +1541,8 @@ github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzp
github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/matttproud/golang_protobuf_extensions v1.0.2/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
@ -1844,14 +1845,13 @@ github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
github.com/sftpgo/sdk v0.1.5-0.20230524172149-afb96ebee860 h1:adaUl1JO/4bPhQuhSH7bQJ2o+2CW6Ry7R2w2SltS/PE=
github.com/sftpgo/sdk v0.1.5-0.20230524172149-afb96ebee860/go.mod h1:TjeoMWS0JEXt9RukJveTnaiHj4+MVLtUiDC+mY++Odk=
github.com/shirou/gopsutil/v3 v3.23.4 h1:hZwmDxZs7Ewt75DV81r4pFMqbq+di2cbt9FsQBqLD2o=
github.com/shirou/gopsutil/v3 v3.23.4/go.mod h1:ZcGxyfzAMRevhUR2+cfhXDH6gQdFYE/t8j1nsU4mPI8=
github.com/shoenig/go-m1cpu v0.1.5/go.mod h1:Wwvst4LR89UxjeFtLRMrpgRiyY4xPsejnVZym39dbAQ=
github.com/shirou/gopsutil/v3 v3.23.5 h1:5SgDCeQ0KW0S4N0znjeM/eFHXXOKyv2dVNgRq/c9P6Y=
github.com/shirou/gopsutil/v3 v3.23.5/go.mod h1:Ng3Maa27Q2KARVJ0SPZF5NdrQSC3XHKP8IIWrHgMeLY=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.0/go.mod h1:xYtyGBC5Q3kzCNyJg/SjgNpfAa2kvmgA0i5+lQso8x0=
github.com/shoenig/test v0.6.3/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
@ -1904,8 +1904,8 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw=
github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU=
github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc=
github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg=
github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980/go.mod h1:AO3tvPzVZ/ayst6UlUKUv6rcPQInYe3IknH3jYhAKu8=
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
@ -1930,8 +1930,9 @@ github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/studio-b12/gowebdav v0.0.0-20230203202212-3282f94193f2 h1:VsBj3UD2xyAOu7kJw6O/2jjG2UXLFoBzihqDU9Ofg9M=
github.com/studio-b12/gowebdav v0.0.0-20230203202212-3282f94193f2/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
@ -1946,8 +1947,9 @@ github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955u
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM=
github.com/tklauser/go-sysconf v0.3.11/go.mod h1:GqXfhXY3kiPa0nAXPDIQIWzJbMCB7AmcWpGR8lSZfqI=
github.com/tklauser/numcpus v0.6.0 h1:kebhY2Qt+3U6RNK7UqpYNA+tJ23IBEGKkB7JQBfDYms=
github.com/tklauser/numcpus v0.6.0/go.mod h1:FEZLMke0lhOUG6w2JadTzp0a+Nl8PF/GFkQ5UVIcaL4=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
@ -1975,8 +1977,8 @@ github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSV
github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
github.com/wneessen/go-mail v0.3.9 h1:Q4DbCk3htT5DtDWKeMgNXCiHc4bBY/vv/XQPT6XDXzc=
github.com/wneessen/go-mail v0.3.9/go.mod h1:zxOlafWCP/r6FEhAaRgH4IC1vg2YXxO0Nar9u0IScZ8=
github.com/wneessen/go-mail v0.3.10-0.20230531074101-4100fef083df h1:/fA+c44SSsuvyY7m8N/Nm+AQ5ro2docGG7jz9qK9KxY=
github.com/wneessen/go-mail v0.3.10-0.20230531074101-4100fef083df/go.mod h1:zxOlafWCP/r6FEhAaRgH4IC1vg2YXxO0Nar9u0IScZ8=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
@ -1998,7 +2000,6 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
@ -2459,7 +2460,6 @@ golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@ -2592,8 +2592,8 @@ golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -2670,8 +2670,8 @@ google.golang.org/api v0.106.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/
google.golang.org/api v0.107.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY=
google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY=
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
google.golang.org/api v0.124.0 h1:dP6Ef1VgOGqQ8eiv4GiY8RhmeyqzovcXBYPDUYG8Syo=
google.golang.org/api v0.124.0/go.mod h1:xu2HQurE5gi/3t1aFCvhPD781p0a3p11sdunTJ2BlP4=
google.golang.org/api v0.125.0 h1:7xGvEY4fyWbhWMHf3R2/4w7L4fXyfpRGE9g6lp8+DCk=
google.golang.org/api v0.125.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -2816,12 +2816,12 @@ google.golang.org/genproto v0.0.0-20230113154510-dbe35b8444a5/go.mod h1:RGgjbofJ
google.golang.org/genproto v0.0.0-20230124163310-31e0e69b6fc2/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
google.golang.org/genproto v0.0.0-20230125152338-dcaf20b6aeaa/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
google.golang.org/genproto v0.0.0-20230526203410-71b5a4ffd15e h1:Ao9GzfUMPH3zjVfzXG5rlWlk+Q8MXWKwWpwVQE1MXfw=
google.golang.org/genproto v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:zqTuNwFlFRsw5zIts5VnzLQxSRqh+CGOTVMlYbY0Eyk=
google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e h1:AZX1ra8YbFMSb7+1pI8S9v4rrgRR7jU1FmuFSSjTVcQ=
google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e h1:NumxXLPfHSndr3wBBdeKiVHjGVFzi9RX2HwwQke94iY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc h1:8DyZCyvI8mE1IdLy/60bS+52xfymkE72wv1asokgtao=
google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64=
google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc h1:kVKPf/IiYSBWEWtkIn6wZXwWGCnLKcC8oWfZvXjsGnM=
google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc h1:XSJ8Vk1SWuNr8S18z1NZSziL0CPIXLCCMDOEFtHBOFc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/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=

View file

@ -142,6 +142,70 @@ func (c *SFTPDConfigs) getACopy() *SFTPDConfigs {
}
}
func validateSMTPSecret(secret *kms.Secret, name string) error {
if secret.IsRedacted() {
return util.NewValidationError(fmt.Sprintf("cannot save a redacted smtp %s", name))
}
if secret.IsEncrypted() && !secret.IsValid() {
return util.NewValidationError(fmt.Sprintf("invalid encrypted smtp %s", name))
}
if !secret.IsEmpty() && !secret.IsValidInput() {
return util.NewValidationError(fmt.Sprintf("invalid smtp %s", name))
}
if secret.IsPlain() {
secret.SetAdditionalData("smtp")
if err := secret.Encrypt(); err != nil {
return util.NewValidationError(fmt.Sprintf("could not encrypt smtp %s: %v", name, err))
}
}
return nil
}
// SMTPOAuth2 defines the SMTP related OAuth2 configurations
type SMTPOAuth2 struct {
Provider int `json:"provider,omitempty"`
Tenant string `json:"tenant,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecret *kms.Secret `json:"client_secret,omitempty"`
RefreshToken *kms.Secret `json:"refresh_token,omitempty"`
}
func (c *SMTPOAuth2) validate() error {
if c.Provider < 0 || c.Provider > 1 {
return util.NewValidationError("smtp oauth2: unsupported provider")
}
if c.ClientID == "" {
return util.NewValidationError("smtp oauth2: client id is required")
}
if c.ClientSecret == nil {
return util.NewValidationError("smtp oauth2: client secret is required")
}
if c.RefreshToken == nil {
return util.NewValidationError("smtp oauth2: refresh token is required")
}
if err := validateSMTPSecret(c.ClientSecret, "oauth2 client secret"); err != nil {
return err
}
return validateSMTPSecret(c.RefreshToken, "oauth2 refresh token")
}
func (c *SMTPOAuth2) getACopy() SMTPOAuth2 {
var clientSecret, refreshToken *kms.Secret
if c.ClientSecret != nil {
clientSecret = c.ClientSecret.Clone()
}
if c.RefreshToken != nil {
refreshToken = c.RefreshToken.Clone()
}
return SMTPOAuth2{
Provider: c.Provider,
Tenant: c.Tenant,
ClientID: c.ClientID,
ClientSecret: clientSecret,
RefreshToken: refreshToken,
}
}
// SMTPConfigs defines configuration for SMTP
type SMTPConfigs struct {
Host string `json:"host,omitempty"`
@ -153,52 +217,63 @@ type SMTPConfigs struct {
Encryption int `json:"encryption,omitempty"`
Domain string `json:"domain,omitempty"`
Debug int `json:"debug,omitempty"`
OAuth2 SMTPOAuth2 `json:"oauth2"`
}
func (c *SMTPConfigs) isEmpty() bool {
// IsEmpty returns true if the configuration is empty
func (c *SMTPConfigs) IsEmpty() bool {
return c.Host == ""
}
func (c *SMTPConfigs) validatePassword() error {
if c.Password != nil {
if c.Password.IsRedacted() {
return util.NewValidationError("cannot save a redacted smtp password")
}
if c.Password.IsEncrypted() && !c.Password.IsValid() {
return util.NewValidationError("invalid encrypted smtp password")
}
if !c.Password.IsEmpty() && !c.Password.IsValidInput() {
return util.NewValidationError("invalid smtp password")
}
if c.Password.IsPlain() {
c.Password.SetAdditionalData("smtp")
if err := c.Password.Encrypt(); err != nil {
return util.NewValidationError(fmt.Sprintf("could not encrypt smtp password: %v", err))
}
}
}
return nil
}
func (c *SMTPConfigs) validate() error {
if c.isEmpty() {
if c.IsEmpty() {
return nil
}
if c.Port <= 0 || c.Port > 65535 {
return util.NewValidationError(fmt.Sprintf("smtp: invalid port %d", c.Port))
}
if err := c.validatePassword(); err != nil {
return err
if c.Password != nil && c.AuthType != 3 {
if err := validateSMTPSecret(c.Password, "password"); err != nil {
return err
}
}
if c.User == "" && c.From == "" {
return util.NewValidationError("smtp: from address and user cannot both be empty")
}
if c.AuthType < 0 || c.AuthType > 2 {
if c.AuthType < 0 || c.AuthType > 3 {
return util.NewValidationError(fmt.Sprintf("smtp: invalid auth type %d", c.AuthType))
}
if c.Encryption < 0 || c.Encryption > 2 {
return util.NewValidationError(fmt.Sprintf("smtp: invalid encryption %d", c.Encryption))
}
if c.AuthType == 3 {
c.Password = kms.NewEmptySecret()
return c.OAuth2.validate()
}
c.OAuth2 = SMTPOAuth2{}
return nil
}
// TryDecrypt tries to decrypt the encrypted secrets
func (c *SMTPConfigs) TryDecrypt() error {
if c.Password == nil {
c.Password = kms.NewEmptySecret()
}
if c.OAuth2.ClientSecret == nil {
c.OAuth2.ClientSecret = kms.NewEmptySecret()
}
if c.OAuth2.RefreshToken == nil {
c.OAuth2.RefreshToken = kms.NewEmptySecret()
}
if err := c.Password.TryDecrypt(); err != nil {
return fmt.Errorf("unable to decrypt smtp password: %w", err)
}
if err := c.OAuth2.ClientSecret.TryDecrypt(); err != nil {
return fmt.Errorf("unable to decrypt smtp oauth2 client secret: %w", err)
}
if err := c.OAuth2.RefreshToken.TryDecrypt(); err != nil {
return fmt.Errorf("unable to decrypt smtp oauth2 refresh token: %w", err)
}
return nil
}
@ -217,6 +292,7 @@ func (c *SMTPConfigs) getACopy() *SMTPConfigs {
Encryption: c.Encryption,
Domain: c.Domain,
Debug: c.Debug,
OAuth2: c.OAuth2.getACopy(),
}
}
@ -315,16 +391,30 @@ func (c *Configs) PrepareForRendering() {
if c.SFTPD != nil && c.SFTPD.isEmpty() {
c.SFTPD = nil
}
if c.SMTP != nil && c.SMTP.isEmpty() {
if c.SMTP != nil && c.SMTP.IsEmpty() {
c.SMTP = nil
}
if c.ACME != nil && c.ACME.isEmpty() {
c.ACME = nil
}
if c.SMTP != nil && c.SMTP.Password != nil {
c.SMTP.Password.Hide()
if c.SMTP.Password.IsEmpty() {
c.SMTP.Password = nil
if c.SMTP != nil {
if c.SMTP.Password != nil {
c.SMTP.Password.Hide()
if c.SMTP.Password.IsEmpty() {
c.SMTP.Password = nil
}
}
if c.SMTP.OAuth2.ClientSecret != nil {
c.SMTP.OAuth2.ClientSecret.Hide()
if c.SMTP.OAuth2.ClientSecret.IsEmpty() {
c.SMTP.OAuth2.ClientSecret = nil
}
}
if c.SMTP.OAuth2.RefreshToken != nil {
c.SMTP.OAuth2.RefreshToken.Hide()
if c.SMTP.OAuth2.RefreshToken.IsEmpty() {
c.SMTP.OAuth2.RefreshToken = nil
}
}
}
}
@ -340,6 +430,12 @@ func (c *Configs) SetNilsToEmpty() {
if c.SMTP.Password == nil {
c.SMTP.Password = kms.NewEmptySecret()
}
if c.SMTP.OAuth2.ClientSecret == nil {
c.SMTP.OAuth2.ClientSecret = kms.NewEmptySecret()
}
if c.SMTP.OAuth2.RefreshToken == nil {
c.SMTP.OAuth2.RefreshToken = kms.NewEmptySecret()
}
if c.ACME == nil {
c.ACME = &ACMEConfigs{}
}

View file

@ -27,6 +27,7 @@ const (
SessionTypeOIDCAuth SessionType = iota + 1
SessionTypeOIDCToken
SessionTypeResetCode
SessionTypeOAuth2Auth
)
// Session defines a shared session persisted in the data provider
@ -41,7 +42,7 @@ func (s *Session) validate() error {
if s.Key == "" {
return errors.New("unable to save a session with an empty key")
}
if s.Type < SessionTypeOIDCAuth || s.Type > SessionTypeResetCode {
if s.Type < SessionTypeOIDCAuth || s.Type > SessionTypeOAuth2Auth {
return fmt.Errorf("invalid session type: %v", s.Type)
}
return nil

View file

@ -18,9 +18,13 @@ import (
"net/http"
"github.com/go-chi/render"
"github.com/rs/xid"
"golang.org/x/oauth2"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/smtp"
"github.com/drakkan/sftpgo/v2/internal/util"
)
type smtpTestRequest struct {
@ -28,6 +32,10 @@ type smtpTestRequest struct {
Recipient string `json:"recipient"`
}
func (r *smtpTestRequest) hasRedactedSecret() bool {
return r.Password == redactedSecret || r.OAuth2.ClientSecret == redactedSecret || r.OAuth2.RefreshToken == redactedSecret
}
func testSMTPConfig(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
@ -37,15 +45,29 @@ func testSMTPConfig(w http.ResponseWriter, r *http.Request) {
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
return
}
if req.Password == redactedSecret {
if req.hasRedactedSecret() {
configs, err := dataprovider.GetConfigs()
if err != nil {
sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
return
}
configs.SetNilsToEmpty()
if err := configs.SMTP.Password.TryDecrypt(); err == nil {
req.Password = configs.SMTP.Password.GetPayload()
if err := configs.SMTP.TryDecrypt(); err == nil {
if req.Password == redactedSecret {
req.Password = configs.SMTP.Password.GetPayload()
}
if req.OAuth2.ClientSecret == redactedSecret {
req.OAuth2.ClientSecret = configs.SMTP.OAuth2.ClientSecret.GetPayload()
}
if req.OAuth2.RefreshToken == redactedSecret {
req.OAuth2.RefreshToken = configs.SMTP.OAuth2.RefreshToken.GetPayload()
}
}
}
if req.AuthType == 3 {
if err := req.Config.OAuth2.Validate(); err != nil {
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
return
}
}
if err := req.SendEmail([]string{req.Recipient}, nil, "SFTPGo - Testing Email Settings",
@ -55,3 +77,47 @@ func testSMTPConfig(w http.ResponseWriter, r *http.Request) {
}
sendAPIResponse(w, r, nil, "SMTP connection OK", http.StatusOK)
}
type oauth2TokenRequest struct {
smtp.OAuth2Config
BaseRedirectURL string `json:"base_redirect_url"`
}
func handleSMTPOAuth2TokenRequestPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
var req oauth2TokenRequest
err := render.DecodeJSON(r.Body, &req)
if err != nil {
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
return
}
if req.BaseRedirectURL == "" {
sendAPIResponse(w, r, nil, "base redirect url is required", http.StatusBadRequest)
return
}
if req.ClientSecret == redactedSecret {
configs, err := dataprovider.GetConfigs()
if err != nil {
sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
return
}
configs.SetNilsToEmpty()
if err := configs.SMTP.TryDecrypt(); err == nil {
req.OAuth2Config.ClientSecret = configs.SMTP.OAuth2.ClientSecret.GetPayload()
}
}
cfg := req.OAuth2Config.GetOAuth2()
cfg.RedirectURL = req.BaseRedirectURL + webOAuth2RedirectPath
clientSecret := kms.NewPlainSecret(cfg.ClientSecret)
clientSecret.SetAdditionalData(xid.New().String())
pendingAuth := newOAuth2PendingAuth(req.Provider, cfg.RedirectURL, cfg.ClientID, clientSecret)
oauth2Mgr.addPendingAuth(pendingAuth)
stateToken := createOAuth2Token(pendingAuth.State, util.GetIPFromRemoteAddress(r.RemoteAddr))
if stateToken == "" {
sendAPIResponse(w, r, nil, "unable to create state token", http.StatusInternalServerError)
return
}
u := cfg.AuthCodeURL(stateToken, oauth2.AccessTypeOffline)
sendAPIResponse(w, r, nil, u, http.StatusOK)
}

View file

@ -40,6 +40,7 @@ const (
tokenAudienceAPI tokenAudience = "API"
tokenAudienceAPIUser tokenAudience = "APIUser"
tokenAudienceCSRF tokenAudience = "CSRF"
tokenAudienceOAuth2 tokenAudience = "OAuth2"
)
type tokenValidation = int
@ -417,3 +418,47 @@ func verifyCSRFToken(tokenString, ip string) error {
return nil
}
func createOAuth2Token(state, ip string) string {
claims := make(map[string]any)
now := time.Now().UTC()
claims[jwt.JwtIDKey] = state
claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
claims[jwt.ExpirationKey] = now.Add(3 * time.Minute)
claims[jwt.AudienceKey] = []string{tokenAudienceOAuth2, ip}
_, tokenString, err := csrfTokenAuth.Encode(claims)
if err != nil {
logger.Debug(logSender, "", "unable to create OAuth2 token: %v", err)
return ""
}
return tokenString
}
func verifyOAuth2Token(tokenString, ip string) (string, error) {
token, err := jwtauth.VerifyToken(csrfTokenAuth, tokenString)
if err != nil || token == nil {
logger.Debug(logSender, "", "error validating OAuth2 token %q: %v", tokenString, err)
return "", fmt.Errorf("unable to verify OAuth2 state: %v", err)
}
if !util.Contains(token.Audience(), tokenAudienceOAuth2) {
logger.Debug(logSender, "", "error validating OAuth2 token audience")
return "", errors.New("invalid OAuth2 state")
}
if tokenValidationMode != tokenValidationNoIPMatch {
if !util.Contains(token.Audience(), ip) {
logger.Debug(logSender, "", "error validating OAuth2 token IP audience")
return "", errors.New("invalid OAuth2 state")
}
}
if val, ok := token.Get(jwt.JwtIDKey); ok {
if state, ok := val.(string); ok {
return state, nil
}
}
logger.Debug(logSender, "", "jti not found in OAuth2 token")
return "", errors.New("invalid OAuth2 state")
}

View file

@ -107,6 +107,8 @@ const (
webAdminLoginPathDefault = "/web/admin/login"
webAdminOIDCLoginPathDefault = "/web/admin/oidclogin"
webOIDCRedirectPathDefault = "/web/oidc/redirect"
webOAuth2RedirectPathDefault = "/web/oauth2/redirect"
webOAuth2TokenPathDefault = "/web/admin/oauth2/token"
webAdminTwoFactorPathDefault = "/web/admin/twofactor"
webAdminTwoFactorRecoveryPathDefault = "/web/admin/twofactor-recovery"
webLogoutPathDefault = "/web/admin/logout"
@ -201,6 +203,8 @@ var (
webBaseAdminPath string
webBaseClientPath string
webOIDCRedirectPath string
webOAuth2RedirectPath string
webOAuth2TokenPath string
webAdminSetupPath string
webAdminOIDCLoginPath string
webAdminLoginPath string
@ -919,6 +923,7 @@ func (c *Conf) Initialize(configDir string, isShared int) error {
configurationDir = configDir
resetCodesMgr = newResetCodeManager(isShared)
oidcMgr = newOIDCManager(isShared)
oauth2Mgr = newOAuth2Manager(isShared)
staticFilesPath := util.FindSharedDataPath(c.StaticFilesPath, configDir)
templatesPath := util.FindSharedDataPath(c.TemplatesPath, configDir)
openAPIPath := util.FindSharedDataPath(c.OpenAPIPath, configDir)
@ -1100,6 +1105,8 @@ func updateWebAdminURLs(baseURL string) {
webBasePath = path.Join(baseURL, webBasePathDefault)
webBaseAdminPath = path.Join(baseURL, webBasePathAdminDefault)
webOIDCRedirectPath = path.Join(baseURL, webOIDCRedirectPathDefault)
webOAuth2RedirectPath = path.Join(baseURL, webOAuth2RedirectPathDefault)
webOAuth2TokenPath = path.Join(baseURL, webOAuth2TokenPathDefault)
webAdminSetupPath = path.Join(baseURL, webAdminSetupPathDefault)
webAdminLoginPath = path.Join(baseURL, webAdminLoginPathDefault)
webAdminOIDCLoginPath = path.Join(baseURL, webAdminOIDCLoginPathDefault)
@ -1176,6 +1183,7 @@ func startCleanupTicker(duration time.Duration) {
resetCodesMgr.Cleanup()
if counter%2 == 0 {
oidcMgr.cleanup()
oauth2Mgr.cleanup()
}
}
}

View file

@ -171,6 +171,7 @@ const (
webAdminRolePath = "/web/admin/role"
webEventsPath = "/web/admin/events"
webConfigsPath = "/web/admin/configs"
webOAuth2TokenPath = "/web/admin/oauth2/token"
webBasePathClient = "/web/client"
webClientLoginPath = "/web/client/login"
webClientFilesPath = "/web/client/files"
@ -1432,6 +1433,37 @@ func TestConfigs(t *testing.T) {
err = dataprovider.UpdateConfigs(&configs, "", "", "")
assert.ErrorIs(t, err, util.ErrValidation)
configs = dataprovider.Configs{
SMTP: &dataprovider.SMTPConfigs{
Host: "mail.example.com",
Port: 587,
User: "test@example.com",
AuthType: 3,
Encryption: 2,
OAuth2: dataprovider.SMTPOAuth2{
Provider: 1,
Tenant: "",
ClientID: "",
},
},
}
err = dataprovider.UpdateConfigs(&configs, "", "", "")
if assert.ErrorIs(t, err, util.ErrValidation) {
assert.Contains(t, err.Error(), "smtp oauth2: client id is required")
}
configs.SMTP.OAuth2 = dataprovider.SMTPOAuth2{
Provider: 1,
ClientID: "client id",
ClientSecret: kms.NewPlainSecret("client secret"),
RefreshToken: kms.NewPlainSecret("refresh token"),
}
err = dataprovider.UpdateConfigs(&configs, "", "", "")
assert.NoError(t, err)
configs, err = dataprovider.GetConfigs()
assert.NoError(t, err)
assert.Equal(t, 3, configs.SMTP.AuthType)
assert.Equal(t, 1, configs.SMTP.OAuth2.Provider)
err = dataprovider.UpdateConfigs(nil, "", "", "")
assert.NoError(t, err)
}
@ -9396,7 +9428,7 @@ func TestSMTPConfig(t *testing.T) {
tokenHeader := "X-CSRF-TOKEN"
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, smtpTestURL, bytes.NewBuffer([]byte("{")))
assert.NoError(t, err)
@ -9453,6 +9485,22 @@ func TestSMTPConfig(t *testing.T) {
checkResponseCode(t, http.StatusInternalServerError, rr)
assert.Contains(t, rr.Body.String(), "server does not support SMTP AUTH")
testReq["password"] = ""
testReq["auth_type"] = 3
testReq["oauth2"] = smtp.OAuth2Config{
ClientSecret: redactedSecret,
RefreshToken: redactedSecret,
}
asJSON, err = json.Marshal(testReq)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, smtpTestURL, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
req.Header.Set(tokenHeader, csrfToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "smtp oauth2: client id is required")
err = dataprovider.UpdateConfigs(nil, "", "", "")
assert.NoError(t, err)
smtpCfg = smtp.Config{}
@ -9460,6 +9508,45 @@ func TestSMTPConfig(t *testing.T) {
require.NoError(t, err)
}
func TestOAuth2TokenRequest(t *testing.T) {
tokenHeader := "X-CSRF-TOKEN"
webToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer([]byte("{")))
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
rr := executeRequest(req)
checkResponseCode(t, http.StatusForbidden, rr)
req.Header.Set(tokenHeader, csrfToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
testReq := make(map[string]any)
testReq["client_secret"] = redactedSecret
asJSON, err := json.Marshal(testReq)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
req.Header.Set(tokenHeader, csrfToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
assert.Contains(t, rr.Body.String(), "base redirect url is required")
testReq["base_redirect_url"] = "http://localhost:8081"
asJSON, err = json.Marshal(testReq)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
req.Header.Set(tokenHeader, csrfToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
}
func TestMFAPermission(t *testing.T) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
@ -12684,6 +12771,9 @@ func TestWebConfigsMock(t *testing.T) {
form.Set("smtp_port", "a") // converted to 587
form.Set("smtp_auth", "1")
form.Set("smtp_encryption", "2")
form.Set("smtp_debug", "checked")
form.Set("smtp_oauth2_provider", "1")
form.Set("smtp_oauth2_client_id", "123")
req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
setJWTCookieForReq(req, webToken)
@ -12702,6 +12792,8 @@ func TestWebConfigsMock(t *testing.T) {
assert.Equal(t, 587, configs.SMTP.Port)
assert.Equal(t, "Example <info@example.net>", configs.SMTP.From)
assert.Equal(t, defaultUsername, configs.SMTP.User)
assert.Equal(t, 1, configs.SMTP.Debug)
assert.Equal(t, "", configs.SMTP.OAuth2.ClientID)
err = configs.SMTP.Password.Decrypt()
assert.NoError(t, err)
assert.Equal(t, defaultPassword, configs.SMTP.Password.GetPayload())
@ -23971,6 +24063,17 @@ func TestProviderClosedMock(t *testing.T) {
rr := executeRequest(req)
checkResponseCode(t, http.StatusInternalServerError, rr)
testReq["base_redirect_url"] = "http://localhost"
testReq["client_secret"] = redactedSecret
asJSON, err = json.Marshal(testReq)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setJWTCookieForReq(req, token)
req.Header.Set("X-CSRF-TOKEN", csrfToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusInternalServerError, rr)
req, err = http.NewRequest(http.MethodGet, webConfigsPath, nil)
assert.NoError(t, err)
setJWTCookieForReq(req, token)

View file

@ -44,6 +44,7 @@ import (
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/rs/xid"
"github.com/sftpgo/sdk"
sdkkms "github.com/sftpgo/sdk/kms"
"github.com/sftpgo/sdk/plugin/notifier"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -971,6 +972,132 @@ func TestRetentionInvalidTokenClaims(t *testing.T) {
assert.NoError(t, err)
}
func TestUpdateSMTPSecrets(t *testing.T) {
currentConfigs := &dataprovider.SMTPConfigs{
OAuth2: dataprovider.SMTPOAuth2{
ClientSecret: kms.NewPlainSecret("client secret"),
RefreshToken: kms.NewPlainSecret("refresh token"),
},
}
redactedClientSecret := kms.NewPlainSecret("secret")
redactedRefreshToken := kms.NewPlainSecret("token")
redactedClientSecret.SetStatus(sdkkms.SecretStatusRedacted)
redactedRefreshToken.SetStatus(sdkkms.SecretStatusRedacted)
newConfigs := &dataprovider.SMTPConfigs{
Password: kms.NewPlainSecret("pwd"),
OAuth2: dataprovider.SMTPOAuth2{
ClientSecret: redactedClientSecret,
RefreshToken: redactedRefreshToken,
},
}
updateSMTPSecrets(newConfigs, currentConfigs)
assert.Nil(t, currentConfigs.Password)
assert.NotNil(t, newConfigs.Password)
assert.Equal(t, currentConfigs.OAuth2.ClientSecret, newConfigs.OAuth2.ClientSecret)
assert.Equal(t, currentConfigs.OAuth2.RefreshToken, newConfigs.OAuth2.RefreshToken)
clientSecret := kms.NewPlainSecret("plain secret")
refreshToken := kms.NewPlainSecret("plain token")
newConfigs = &dataprovider.SMTPConfigs{
Password: kms.NewPlainSecret("pwd"),
OAuth2: dataprovider.SMTPOAuth2{
ClientSecret: clientSecret,
RefreshToken: refreshToken,
},
}
updateSMTPSecrets(newConfigs, currentConfigs)
assert.Equal(t, clientSecret, newConfigs.OAuth2.ClientSecret)
assert.Equal(t, refreshToken, newConfigs.OAuth2.RefreshToken)
}
func TestOAuth2Redirect(t *testing.T) {
server := httpdServer{}
server.initializeRouter()
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, webOAuth2RedirectPath+"?state=invalid", nil)
assert.NoError(t, err)
server.handleOAuth2TokenRedirect(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "token is unauthorized")
ip := "127.1.1.4"
tokenString := createOAuth2Token(xid.New().String(), ip)
rr = httptest.NewRecorder()
req, err = http.NewRequest(http.MethodGet, webOAuth2RedirectPath+"?state="+tokenString, nil)
assert.NoError(t, err)
req.RemoteAddr = ip
server.handleOAuth2TokenRedirect(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
assert.Contains(t, rr.Body.String(), "no auth request found for the specified state")
}
func TestOAuth2Token(t *testing.T) {
// invalid token
_, err := verifyOAuth2Token("token", "")
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "unable to verify OAuth2 state")
}
// bad audience
claims := make(map[string]any)
now := time.Now().UTC()
claims[jwt.JwtIDKey] = xid.New().String()
claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
claims[jwt.ExpirationKey] = now.Add(tokenDuration)
claims[jwt.AudienceKey] = []string{tokenAudienceAPI}
_, tokenString, err := csrfTokenAuth.Encode(claims)
assert.NoError(t, err)
_, err = verifyOAuth2Token(tokenString, "")
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "invalid OAuth2 state")
}
// bad IP
tokenString = createOAuth2Token("state", "127.1.1.1")
_, err = verifyOAuth2Token(tokenString, "127.1.1.2")
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "invalid OAuth2 state")
}
// ok
state := xid.New().String()
tokenString = createOAuth2Token(state, "127.1.1.3")
s, err := verifyOAuth2Token(tokenString, "127.1.1.3")
assert.NoError(t, err)
assert.Equal(t, state, s)
// no jti
claims = make(map[string]any)
claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
claims[jwt.ExpirationKey] = now.Add(tokenDuration)
claims[jwt.AudienceKey] = []string{tokenAudienceOAuth2, "127.1.1.4"}
_, tokenString, err = csrfTokenAuth.Encode(claims)
assert.NoError(t, err)
_, err = verifyOAuth2Token(tokenString, "127.1.1.4")
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "invalid OAuth2 state")
}
// encode error
csrfTokenAuth = jwtauth.New("HT256", util.GenerateRandomBytes(32), nil)
tokenString = createOAuth2Token(xid.New().String(), "")
assert.Empty(t, tokenString)
server := httpdServer{}
server.initializeRouter()
rr := httptest.NewRecorder()
testReq := make(map[string]any)
testReq["base_redirect_url"] = "http://localhost:8082"
asJSON, err := json.Marshal(testReq)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, webOAuth2TokenPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
handleSMTPOAuth2TokenRequestPost(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
assert.Contains(t, rr.Body.String(), "unable to create state token")
csrfTokenAuth = jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil)
}
func TestCSRFToken(t *testing.T) {
// invalid token
err := verifyCSRFToken("token", "")

170
internal/httpd/oauth2.go Normal file
View file

@ -0,0 +1,170 @@
// Copyright (C) 2019-2023 Nicola Murino
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package httpd
import (
"encoding/json"
"errors"
"sync"
"time"
"github.com/rs/xid"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
)
var (
oauth2Mgr oauth2Manager
)
func newOAuth2Manager(isShared int) oauth2Manager {
if isShared == 1 {
logger.Info(logSender, "", "using provider OAuth2 manager")
return &dbOAuth2Manager{}
}
logger.Info(logSender, "", "using memory OAuth2 manager")
return &memoryOAuth2Manager{
pendingAuths: make(map[string]oauth2PendingAuth),
}
}
type oauth2PendingAuth struct {
State string `json:"state"`
Provider int `json:"provider"`
ClientID string `json:"client_id"`
ClientSecret *kms.Secret `json:"client_secret"`
RedirectURL string `json:"redirect_url"`
IssuedAt int64 `json:"issued_at"`
}
func newOAuth2PendingAuth(provider int, redirectURL, clientID string, clientSecret *kms.Secret) oauth2PendingAuth {
return oauth2PendingAuth{
State: xid.New().String(),
Provider: provider,
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
IssuedAt: util.GetTimeAsMsSinceEpoch(time.Now()),
}
}
type oauth2Manager interface {
addPendingAuth(pendingAuth oauth2PendingAuth)
removePendingAuth(state string)
getPendingAuth(state string) (oauth2PendingAuth, error)
cleanup()
}
type memoryOAuth2Manager struct {
mu sync.RWMutex
pendingAuths map[string]oauth2PendingAuth
}
func (o *memoryOAuth2Manager) addPendingAuth(pendingAuth oauth2PendingAuth) {
o.mu.Lock()
defer o.mu.Unlock()
o.pendingAuths[pendingAuth.State] = pendingAuth
}
func (o *memoryOAuth2Manager) removePendingAuth(state string) {
o.mu.Lock()
defer o.mu.Unlock()
delete(o.pendingAuths, state)
}
func (o *memoryOAuth2Manager) getPendingAuth(state string) (oauth2PendingAuth, error) {
o.mu.RLock()
defer o.mu.RUnlock()
authReq, ok := o.pendingAuths[state]
if !ok {
return oauth2PendingAuth{}, errors.New("oauth2: no auth request found for the specified state")
}
diff := util.GetTimeAsMsSinceEpoch(time.Now()) - authReq.IssuedAt
if diff > authStateValidity {
return oauth2PendingAuth{}, errors.New("oauth2: auth request is too old")
}
return authReq, nil
}
func (o *memoryOAuth2Manager) cleanup() {
logger.Debug(logSender, "", "oauth2 manager cleanup")
o.mu.Lock()
defer o.mu.Unlock()
for k, auth := range o.pendingAuths {
diff := util.GetTimeAsMsSinceEpoch(time.Now()) - auth.IssuedAt
// remove old pending auth requests
if diff < 0 || diff > authStateValidity {
delete(o.pendingAuths, k)
}
}
}
type dbOAuth2Manager struct{}
func (o *dbOAuth2Manager) addPendingAuth(pendingAuth oauth2PendingAuth) {
if err := pendingAuth.ClientSecret.Encrypt(); err != nil {
logger.Error(logSender, "", "unable to encrypt oauth2 secret: %v", err)
return
}
session := dataprovider.Session{
Key: pendingAuth.State,
Data: pendingAuth,
Type: dataprovider.SessionTypeOAuth2Auth,
Timestamp: pendingAuth.IssuedAt + authStateValidity,
}
dataprovider.AddSharedSession(session) //nolint:errcheck
}
func (o *dbOAuth2Manager) removePendingAuth(state string) {
dataprovider.DeleteSharedSession(state) //nolint:errcheck
}
func (o *dbOAuth2Manager) getPendingAuth(state string) (oauth2PendingAuth, error) {
session, err := dataprovider.GetSharedSession(state)
if err != nil {
return oauth2PendingAuth{}, errors.New("oauth2: unable to get the auth request for the specified state")
}
if session.Timestamp < util.GetTimeAsMsSinceEpoch(time.Now()) {
// expired
return oauth2PendingAuth{}, errors.New("oauth2: auth request is too old")
}
return o.decodePendingAuthData(session.Data)
}
func (o *dbOAuth2Manager) decodePendingAuthData(data any) (oauth2PendingAuth, error) {
if val, ok := data.([]byte); ok {
authReq := oauth2PendingAuth{}
err := json.Unmarshal(val, &authReq)
if err != nil {
return authReq, err
}
err = authReq.ClientSecret.TryDecrypt()
return authReq, err
}
logger.Error(logSender, "", "invalid oauth2 auth request data type %T", data)
return oauth2PendingAuth{}, errors.New("oauth2: invalid auth request data")
}
func (o *dbOAuth2Manager) cleanup() {
logger.Debug(logSender, "", "oauth2 manager cleanup")
dataprovider.CleanupSharedSessions(dataprovider.SessionTypeOAuth2Auth, time.Now()) //nolint:errcheck
}

View file

@ -0,0 +1,135 @@
// Copyright (C) 2019-2023 Nicola Murino
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package httpd
import (
"encoding/json"
"testing"
"time"
"github.com/rs/xid"
sdkkms "github.com/sftpgo/sdk/kms"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/util"
)
func TestMemoryOAuth2Manager(t *testing.T) {
mgr := newOAuth2Manager(0)
m, ok := mgr.(*memoryOAuth2Manager)
require.True(t, ok)
require.Len(t, m.pendingAuths, 0)
_, err := m.getPendingAuth(xid.New().String())
require.Error(t, err)
assert.Contains(t, err.Error(), "no auth request found")
auth := newOAuth2PendingAuth(1, "https://...", "cid", kms.NewPlainSecret("mysecret"))
m.addPendingAuth(auth)
require.Len(t, m.pendingAuths, 1)
a, err := m.getPendingAuth(auth.State)
assert.NoError(t, err)
assert.Equal(t, auth.State, a.State)
assert.Equal(t, sdkkms.SecretStatusPlain, a.ClientSecret.GetStatus())
m.removePendingAuth(auth.State)
_, err = m.getPendingAuth(auth.State)
require.Error(t, err)
assert.Contains(t, err.Error(), "no auth request found")
require.Len(t, m.pendingAuths, 0)
state := xid.New().String()
auth = oauth2PendingAuth{
State: state,
Provider: 1,
IssuedAt: util.GetTimeAsMsSinceEpoch(time.Now()),
}
m.addPendingAuth(auth)
auth = oauth2PendingAuth{
State: xid.New().String(),
Provider: 1,
IssuedAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(-10 * time.Minute)),
}
m.addPendingAuth(auth)
require.Len(t, m.pendingAuths, 2)
_, err = m.getPendingAuth(auth.State)
require.Error(t, err)
assert.Contains(t, err.Error(), "auth request is too old")
m.cleanup()
require.Len(t, m.pendingAuths, 1)
m.removePendingAuth(state)
require.Len(t, m.pendingAuths, 0)
}
func TestDbOAuth2Manager(t *testing.T) {
if !isSharedProviderSupported() {
t.Skip("this test it is not available with this provider")
}
mgr := newOAuth2Manager(1)
m, ok := mgr.(*dbOAuth2Manager)
require.True(t, ok)
_, err := m.getPendingAuth(xid.New().String())
require.Error(t, err)
auth := newOAuth2PendingAuth(1, "https://...", "client_id", kms.NewPlainSecret("my db secret"))
m.addPendingAuth(auth)
a, err := m.getPendingAuth(auth.State)
assert.NoError(t, err)
assert.Equal(t, sdkkms.SecretStatusPlain, a.ClientSecret.GetStatus())
session, err := dataprovider.GetSharedSession(auth.State)
assert.NoError(t, err)
authReq := oauth2PendingAuth{}
err = json.Unmarshal(session.Data.([]byte), &authReq)
assert.NoError(t, err)
assert.Equal(t, sdkkms.SecretStatusSecretBox, authReq.ClientSecret.GetStatus())
m.cleanup()
_, err = m.getPendingAuth(auth.State)
assert.NoError(t, err)
m.removePendingAuth(auth.State)
_, err = m.getPendingAuth(auth.State)
assert.Error(t, err)
auth = oauth2PendingAuth{
State: xid.New().String(),
Provider: 1,
IssuedAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(-10 * time.Minute)),
ClientSecret: kms.NewPlainSecret("db secret"),
}
m.addPendingAuth(auth)
_, err = m.getPendingAuth(auth.State)
assert.Error(t, err)
_, err = dataprovider.GetSharedSession(auth.State)
assert.NoError(t, err)
m.cleanup()
_, err = dataprovider.GetSharedSession(auth.State)
assert.Error(t, err)
_, err = m.decodePendingAuthData("not a byte array")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid auth request data")
_, err = m.decodePendingAuthData([]byte("{not a json"))
require.Error(t, err)
// adding a request with a non plain secret will fail
auth = oauth2PendingAuth{
State: xid.New().String(),
Provider: 1,
IssuedAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(-10 * time.Minute)),
ClientSecret: kms.NewPlainSecret("db secret"),
}
auth.ClientSecret.SetStatus(sdkkms.SecretStatusSecretBox)
m.addPendingAuth(auth)
_, err = dataprovider.GetSharedSession(auth.State)
assert.Error(t, err)
asJSON, err := json.Marshal(auth)
assert.NoError(t, err)
_, err = m.decodePendingAuthData(asJSON)
assert.Error(t, err)
}

View file

@ -1573,6 +1573,7 @@ func (s *httpdServer) setupWebAdminRoutes() {
if s.binding.OIDC.hasRoles() && !s.binding.isWebAdminOIDCLoginDisabled() {
s.router.Get(webAdminOIDCLoginPath, s.handleWebAdminOIDCLogin)
}
s.router.Get(webOAuth2RedirectPath, s.handleOAuth2TokenRedirect)
s.router.Get(webAdminSetupPath, s.handleWebAdminSetupGet)
s.router.Post(webAdminSetupPath, s.handleWebAdminSetupPost)
if !s.binding.isWebAdminLoginFormDisabled() {
@ -1745,6 +1746,8 @@ func (s *httpdServer) setupWebAdminRoutes() {
router.With(s.checkPerm(dataprovider.PermAdminManageSystem)).Post(webConfigsPath, s.handleWebConfigsPost)
router.With(s.checkPerm(dataprovider.PermAdminManageSystem), verifyCSRFHeader, s.refreshCookie).
Post(webConfigsPath+"/smtp/test", testSMTPConfig)
router.With(s.checkPerm(dataprovider.PermAdminManageSystem), verifyCSRFHeader, s.refreshCookie).
Post(webOAuth2TokenPath, handleSMTPOAuth2TokenRequestPost)
})
}
}

View file

@ -15,6 +15,7 @@
package httpd
import (
"context"
"errors"
"fmt"
"html/template"
@ -393,10 +394,12 @@ type eventsPage struct {
type configsPage struct {
basePage
Configs dataprovider.Configs
ConfigSection int
RedactedSecret string
Error string
Configs dataprovider.Configs
ConfigSection int
RedactedSecret string
OAuth2TokenURL string
OAuth2RedirectURL string
Error string
}
type messagePage struct {
@ -904,16 +907,20 @@ func (s *httpdServer) renderConfigsPage(w http.ResponseWriter, r *http.Request,
configs.SetNilsToEmpty()
if configs.SMTP.Port == 0 {
configs.SMTP.Port = 587
configs.SMTP.AuthType = 1
configs.SMTP.Encryption = 2
}
if configs.ACME.HTTP01Challenge.Port == 0 {
configs.ACME.HTTP01Challenge.Port = 80
}
data := configsPage{
basePage: s.getBasePageData(pageConfigsTitle, webConfigsPath, r),
Configs: configs,
ConfigSection: section,
RedactedSecret: redactedSecret,
Error: error,
basePage: s.getBasePageData(pageConfigsTitle, webConfigsPath, r),
Configs: configs,
ConfigSection: section,
RedactedSecret: redactedSecret,
OAuth2TokenURL: webOAuth2TokenPath,
OAuth2RedirectURL: webOAuth2RedirectPath,
Error: error,
}
renderAdminTemplate(w, templateConfigs, data)
@ -2639,6 +2646,10 @@ func getSMTPConfigsFromPostFields(r *http.Request) *dataprovider.SMTPConfigs {
if r.Form.Get("smtp_debug") != "" {
debug = 1
}
oauth2Provider := 0
if r.Form.Get("smtp_oauth2_provider") == "1" {
oauth2Provider = 1
}
return &dataprovider.SMTPConfigs{
Host: r.Form.Get("smtp_host"),
Port: port,
@ -2649,6 +2660,13 @@ func getSMTPConfigsFromPostFields(r *http.Request) *dataprovider.SMTPConfigs {
Encryption: encryption,
Domain: r.Form.Get("smtp_domain"),
Debug: debug,
OAuth2: dataprovider.SMTPOAuth2{
Provider: oauth2Provider,
Tenant: strings.TrimSpace(r.Form.Get("smtp_oauth2_tenant")),
ClientID: strings.TrimSpace(r.Form.Get("smtp_oauth2_client_id")),
ClientSecret: getSecretFromFormField(r, "smtp_oauth2_client_secret"),
RefreshToken: getSecretFromFormField(r, "smtp_oauth2_refresh_token"),
},
}
}
@ -4137,9 +4155,7 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques
case "smtp_submit":
configSection = 3
smtpConfigs := getSMTPConfigsFromPostFields(r)
if smtpConfigs.Password.IsNotPlainAndNotEmpty() {
smtpConfigs.Password = configs.SMTP.Password
}
updateSMTPSecrets(smtpConfigs, configs.SMTP)
configs.SMTP = smtpConfigs
default:
s.renderBadRequestPage(w, r, errors.New("unsupported form action"))
@ -4152,13 +4168,64 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques
return
}
if configSection == 3 {
err := configs.SMTP.Password.TryDecrypt()
err := configs.SMTP.TryDecrypt()
if err == nil {
smtp.Activate(configs.SMTP)
} else {
logger.Error(logSender, "", "unable to decrypt SMTP password, cannot activate configuration")
logger.Error(logSender, "", "unable to decrypt SMTP configuration, cannot activate configuration: %v", err)
}
}
s.renderMessagePage(w, r, "Configurations updated", "", http.StatusOK, nil,
"Configurations has been successfully updated")
}
func (s *httpdServer) handleOAuth2TokenRedirect(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
stateToken := r.URL.Query().Get("state")
errorTitle := "Unable to complete OAuth2 flow"
successTitle := "OAuth2 flow completed"
state, err := verifyOAuth2Token(stateToken, util.GetIPFromRemoteAddress(r.RemoteAddr))
if err != nil {
s.renderMessagePage(w, r, errorTitle, "Invalid auth request:", http.StatusBadRequest, err, "")
return
}
defer oauth2Mgr.removePendingAuth(state)
pendingAuth, err := oauth2Mgr.getPendingAuth(state)
if err != nil {
s.renderMessagePage(w, r, errorTitle, "Unable to validate auth request:", http.StatusInternalServerError, err, "")
return
}
oauth2Config := smtp.OAuth2Config{
Provider: pendingAuth.Provider,
ClientID: pendingAuth.ClientID,
ClientSecret: pendingAuth.ClientSecret.GetPayload(),
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cfg := oauth2Config.GetOAuth2()
cfg.RedirectURL = pendingAuth.RedirectURL
token, err := cfg.Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
s.renderMessagePage(w, r, errorTitle, "Unable to get token:", http.StatusInternalServerError, err, "")
return
}
s.renderMessagePage(w, r, successTitle, "", http.StatusOK, nil,
fmt.Sprintf("Copy the following string, without the quotes, into your SMTP OAuth2 Token configuration: %q", token.RefreshToken))
}
func updateSMTPSecrets(newConfigs, currentConfigs *dataprovider.SMTPConfigs) {
if newConfigs.Password.IsNotPlainAndNotEmpty() {
newConfigs.Password = currentConfigs.Password
}
if newConfigs.OAuth2.ClientSecret.IsNotPlainAndNotEmpty() {
newConfigs.OAuth2.ClientSecret = currentConfigs.OAuth2.ClientSecret
}
if newConfigs.OAuth2.RefreshToken.IsNotPlainAndNotEmpty() {
newConfigs.OAuth2.RefreshToken = currentConfigs.OAuth2.RefreshToken
}
}

165
internal/smtp/oauth2.go Normal file
View file

@ -0,0 +1,165 @@
// Copyright (C) 2019-2023 Nicola Murino
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package smtp provides supports for sending emails
package smtp
import (
"context"
"errors"
"fmt"
"sync"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/microsoft"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
)
// Supported OAuth2 providers
const (
OAuth2ProviderGoogle = iota
OAuth2ProviderMicrosoft
)
var supportedOAuth2Providers = []int{OAuth2ProviderGoogle, OAuth2ProviderMicrosoft}
// OAuth2Config defines OAuth2 settings
type OAuth2Config struct {
Provider int `json:"provider" mapstructure:"provider"`
// Tenant for Microsoft provider, if empty "common" is used
Tenant string `json:"tenant" mapstructure:"tenant"`
// ClientID is the application's ID
ClientID string `json:"client_id" mapstructure:"client_id"`
// ClientSecret is the application's secret
ClientSecret string `json:"client_secret" mapstructure:"client_secret"`
// Token to use to get/renew access tokens
RefreshToken string `json:"refresh_token" mapstructure:"refresh_token"`
mu *sync.RWMutex
config *oauth2.Config
accessToken *oauth2.Token
}
// Validate validates and initializes the configuration
func (c *OAuth2Config) Validate() error {
if !util.Contains(supportedOAuth2Providers, c.Provider) {
return fmt.Errorf("smtp oauth2: unsupported provider %d", c.Provider)
}
if c.ClientID == "" {
return errors.New("smtp oauth2: client id is required")
}
if c.ClientSecret == "" {
return errors.New("smtp oauth2: client secret is required")
}
if c.RefreshToken == "" {
return errors.New("smtp oauth2: refresh token is required")
}
c.initialize()
return nil
}
func (c *OAuth2Config) isEqual(other *OAuth2Config) bool {
if c.Provider != other.Provider {
return false
}
if c.Tenant != other.Tenant {
return false
}
if c.ClientID != other.ClientID {
return false
}
if c.ClientSecret != other.ClientSecret {
return false
}
if c.RefreshToken != other.RefreshToken {
return false
}
return true
}
func (c *OAuth2Config) getAccessToken() (string, error) {
c.mu.RLock()
if c.accessToken.Expiry.After(time.Now().Add(30 * time.Second)) {
accessToken := c.accessToken.AccessToken
c.mu.RUnlock()
return accessToken, nil
}
logger.Debug(logSender, "", "renew oauth2 token required, current token expires at %s", c.accessToken.Expiry)
token := new(oauth2.Token)
*token = *c.accessToken
c.mu.RUnlock()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
newToken, err := c.config.TokenSource(ctx, token).Token()
if err != nil {
logger.Error(logSender, "", "unable to get new token: %v", err)
return "", err
}
accessToken := newToken.AccessToken
refreshToken := newToken.RefreshToken
if refreshToken != "" && refreshToken != token.RefreshToken {
c.mu.Lock()
c.RefreshToken = refreshToken
c.accessToken = newToken
c.mu.Unlock()
logger.Debug(logSender, "", "oauth2 refresh token changed")
go updateRefreshToken(refreshToken)
}
if accessToken != token.AccessToken {
c.mu.Lock()
c.accessToken = newToken
c.mu.Unlock()
logger.Debug(logSender, "", "new oauth2 token saved, expires at %s", c.accessToken.Expiry)
}
return accessToken, nil
}
func (c *OAuth2Config) initialize() {
c.mu = new(sync.RWMutex)
c.config = c.GetOAuth2()
c.accessToken = &oauth2.Token{
TokenType: "Bearer",
RefreshToken: c.RefreshToken,
}
}
// GetOAuth2 returns the oauth2 configuration for the provided parameters.
func (c *OAuth2Config) GetOAuth2() *oauth2.Config {
var endpoint oauth2.Endpoint
var scopes []string
switch c.Provider {
case OAuth2ProviderMicrosoft:
endpoint = microsoft.AzureADEndpoint(c.Tenant)
scopes = []string{"offline_access", "https://outlook.office.com/SMTP.Send"}
default:
endpoint = google.Endpoint
scopes = []string{"https://mail.google.com/"}
}
return &oauth2.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
Scopes: scopes,
Endpoint: endpoint,
}
}

View file

@ -29,6 +29,7 @@ import (
"github.com/wneessen/go-mail"
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
"github.com/drakkan/sftpgo/v2/internal/kms"
"github.com/drakkan/sftpgo/v2/internal/logger"
"github.com/drakkan/sftpgo/v2/internal/util"
"github.com/drakkan/sftpgo/v2/internal/version"
@ -85,7 +86,15 @@ func (c *activeConfig) Set(cfg *dataprovider.SMTPConfigs) {
Encryption: cfg.Encryption,
Domain: cfg.Domain,
Debug: cfg.Debug,
OAuth2: OAuth2Config{
Provider: cfg.OAuth2.Provider,
Tenant: cfg.OAuth2.Tenant,
ClientID: cfg.OAuth2.ClientID,
ClientSecret: cfg.OAuth2.ClientSecret.GetPayload(),
RefreshToken: cfg.OAuth2.RefreshToken.GetPayload(),
},
}
config.OAuth2.initialize()
}
c.Lock()
@ -159,6 +168,7 @@ type Config struct {
// 0 Plain
// 1 Login
// 2 CRAM-MD5
// 3 OAuth2
AuthType int `json:"auth_type" mapstructure:"auth_type"`
// 0 no encryption
// 1 TLS
@ -171,6 +181,8 @@ type Config struct {
TemplatesPath string `json:"templates_path" mapstructure:"templates_path"`
// Set to 1 to enable debug logs
Debug int `json:"debug" mapstructure:"debug"`
// OAuth2 related settings
OAuth2 OAuth2Config `json:"oauth2" mapstructure:"oauth2"`
}
func (c *Config) isEqual(other *Config) bool {
@ -201,21 +213,24 @@ func (c *Config) isEqual(other *Config) bool {
if c.Debug != other.Debug {
return false
}
return true
return c.OAuth2.isEqual(&other.OAuth2)
}
func (c *Config) validate() error {
if c.Port <= 0 || c.Port > 65535 {
return fmt.Errorf("smtp: invalid port %d", c.Port)
}
if c.AuthType < 0 || c.AuthType > 2 {
if c.AuthType < 0 || c.AuthType > 3 {
return fmt.Errorf("smtp: invalid auth type %d", c.AuthType)
}
if c.Encryption < 0 || c.Encryption > 2 {
return fmt.Errorf("smtp: invalid encryption %d", c.Encryption)
}
if c.From == "" && c.User == "" {
return fmt.Errorf(`smtp: from address and user cannot both be empty`)
return errors.New(`smtp: from address and user cannot both be empty`)
}
if c.AuthType == 3 {
return c.OAuth2.Validate()
}
return nil
}
@ -283,6 +298,8 @@ func (c *Config) getMailClientOptions() []mail.Option {
options = append(options, mail.WithSMTPAuth(mail.SMTPAuthLogin))
case 2:
options = append(options, mail.WithSMTPAuth(mail.SMTPAuthCramMD5))
case 3:
options = append(options, mail.WithSMTPAuth(mail.SMTPAuthXOAUTH2))
default:
options = append(options, mail.WithSMTPAuth(mail.SMTPAuthPlain))
}
@ -341,6 +358,13 @@ func (c *Config) getSMTPClientAndMsg(to, bcc []string, subject, body string, con
if err != nil {
return nil, nil, fmt.Errorf("unable to create mail client: %w", err)
}
if c.AuthType == 3 {
token, err := c.OAuth2.getAccessToken()
if err != nil {
return nil, nil, fmt.Errorf("unable to get oauth2 access token: %w", err)
}
client.SetPassword(token)
}
return client, msg, nil
}
@ -402,10 +426,29 @@ func loadConfigFromProvider() error {
return fmt.Errorf("smtp: unable to load config from provider: %w", err)
}
configs.SetNilsToEmpty()
if err := configs.SMTP.Password.TryDecrypt(); err != nil {
logger.Error(logSender, "", "unable to decrypt password: %v", err)
return fmt.Errorf("smtp: unable to decrypt password: %w", err)
if err := configs.SMTP.TryDecrypt(); err != nil {
logger.Error(logSender, "", "unable to decrypt smtp config: %v", err)
return fmt.Errorf("smtp: unable to decrypt smtp config: %w", err)
}
config.Set(configs.SMTP)
return nil
}
func updateRefreshToken(token string) {
configs, err := dataprovider.GetConfigs()
if err != nil {
logger.Error(logSender, "", "unable to load config from provider, updating refresh token not possible: %v", err)
return
}
configs.SetNilsToEmpty()
if configs.SMTP.IsEmpty() {
logger.Warn(logSender, "", "unable to update refresh token, smtp not configured in the data provider")
return
}
configs.SMTP.OAuth2.RefreshToken = kms.NewPlainSecret(token)
if err := dataprovider.UpdateConfigs(&configs, dataprovider.ActionExecutorSystem, "", ""); err != nil {
logger.Error(logSender, "", "unable to save new refresh token: %v", err)
return
}
logger.Info(logSender, "", "refresh token updated")
}

View file

@ -407,7 +407,14 @@
"encryption": 0,
"domain": "",
"templates_path": "templates",
"debug": 0
"debug": 0,
"oauth2": {
"provider": 0,
"tenant": "",
"client_id": "",
"client_secret": "",
"refresh_token": ""
}
},
"plugins": []
}

View file

@ -239,10 +239,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="form-group row">
<label for="idSMTPAuth" class="col-sm-2 col-form-label">Auth</label>
<div class="col-sm-3">
<select class="form-control selectpicker" id="idSMTPAuth" name="smtp_auth">
<select class="form-control selectpicker" id="idSMTPAuth" name="smtp_auth" onchange="onSMTPAuthChanged(this.value)">
<option value="0" {{if eq .Configs.SMTP.AuthType 0}}selected{{end}}>Plain</option>
<option value="1" {{if eq .Configs.SMTP.AuthType 1}}selected{{end}}>Login</option>
<option value="2" {{if eq .Configs.SMTP.AuthType 2}}selected{{end}}>CRAM-MD5</option>
<option value="3" {{if eq .Configs.SMTP.AuthType 3}}selected{{end}}>OAuth2</option>
</select>
</div>
<div class="col-sm-2"></div>
@ -256,6 +257,59 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
<div class="form-group row smtp-oauth2">
<label for="idSMTPOAuth2Provider" class="col-sm-2 col-form-label">OAuth2 provider</label>
<div class="col-sm-10">
<select class="form-control selectpicker" id="idSMTPOAuth2Provider" name="smtp_oauth2_provider"
onchange="onSMTPOAuth2ProviderChanged(this.value)" aria-describedby="smtpOauth2ProviderHelpBlock">
<option value="0" {{if eq .Configs.SMTP.OAuth2.Provider 0}}selected{{end}}>Google</option>
<option value="1" {{if eq .Configs.SMTP.OAuth2.Provider 1}}selected{{end}}>Microsoft</option>
</select>
<small id="smtpOauth2ProviderHelpBlock" class="form-text text-muted">
</small>
</div>
</div>
<div class="form-group row smtp-oauth2 smtp-oauth2-microsoft">
<label for="idSMTPOauth2Tenant" class="col-sm-2 col-form-label">OAuth2 Tenant</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idSMTPOauth2Tenant" name="smtp_oauth2_tenant" placeholder=""
value="{{.Configs.SMTP.OAuth2.Tenant}}" aria-describedby="smtpOauth2TenantHelpBlock">
<small id="smtpOauth2TenantHelpBlock" class="form-text text-muted">
Azure Active Directory tenant. Typical values are "common", "organizations", "consumers" or tenant identifier.
</small>
</div>
</div>
<div class="form-group row smtp-oauth2">
<label for="idSMTPOauth2ClientID" class="col-sm-2 col-form-label">OAuth2 Client ID</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idSMTPOauth2ClientID" name="smtp_oauth2_client_id" placeholder=""
value="{{.Configs.SMTP.OAuth2.ClientID}}" spellcheck="false">
</div>
</div>
<div class="form-group row smtp-oauth2">
<label for="idSMTPOAuth2ClientSecret" class="col-sm-2 col-form-label">OAuth2 Client secret</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="idSMTPOAuth2ClientSecret" name="smtp_oauth2_client_secret" placeholder="" autocomplete="new-password" spellcheck="false"
value="{{if .Configs.SMTP.OAuth2.ClientSecret.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Configs.SMTP.OAuth2.ClientSecret.GetPayload}}{{end}}">
</div>
</div>
<div class="form-group row smtp-oauth2">
<label for="idSMTPOAuth2RefreshToken" class="col-sm-2 col-form-label">OAuth2 Token</label>
<div class="col-sm-10">
<div class="input-group">
<input type="password" class="form-control" id="idSMTPOAuth2RefreshToken" name="smtp_oauth2_refresh_token" placeholder="" autocomplete="new-password" spellcheck="false"
value="{{if .Configs.SMTP.OAuth2.RefreshToken.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Configs.SMTP.OAuth2.RefreshToken.GetPayload}}{{end}}">
<div class="input-group-append">
<button class="btn btn-secondary px-5" onclick="getRefreshToken(event);">Get</button>
</div>
</div>
</div>
</div>
<div class="form-group row">
<label for="idSMTPFrom" class="col-sm-2 col-form-label">From</label>
<div class="col-sm-10">
@ -341,6 +395,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
</div>
</div>
<div class="modal fade" id="smtpOAuthFlowModal" tabindex="-1" role="dialog" aria-labelledby="smtpOAuthFlowModalLabel"
aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="smtpOAuthFlowModal">
OAuth2 flow
</h5>
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div id="oauth2SuccessMsg" class="card mb-4 border-left-success" style="display: none;">
<div id="oauth2SuccessTxt" class="card-body">To start the OAuth2 flow and get a token follow this <a id="oauth2link" href="#" onclick="dismissOAuthModal();" target="_blank">link</a>.</div>
</div>
<div id="oauth2ErrorMsg" class="card mb-4 border-left-warning" style="display: none;">
<div id="oauth2ErrorTxt" class="card-body text-form-error"></div>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
@ -351,6 +429,72 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
$('#spinnerModal').modal('show');
}
function dismissOAuthModal(){
setTimeout(function () {
$('#smtpOAuthFlowModal').modal('hide');
}, 2000);
}
function getCurrentURI(){
let port = window.location.port;
if (port){
return window.location.protocol+"//"+window.location.hostname+":"+port;
}
return window.location.protocol+"//"+window.location.hostname;
}
function getRefreshToken(event){
event.preventDefault();
$('#oauth2SuccessMsg').hide();
$('#oauth2ErrorMsg').hide();
showSpinner();
let data = {"base_redirect_url": getCurrentURI(), "provider": parseInt($('#idSMTPOAuth2Provider').val()),
"tenant": $('#idSMTPOauth2Tenant').val(), "client_id": $('#idSMTPOauth2ClientID').val(),
"client_secret": $('#idSMTPOAuth2ClientSecret').val()};
$.ajax({
url: "{{.OAuth2TokenURL}}",
type: 'POST',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
data: JSON.stringify(data),
dataType: 'json',
contentType: 'application/json; charset=utf-8',
timeout: 15000,
success: function (result) {
$('#spinnerModal').modal('hide');
spinnerDone = true;
if (result && result.message){
$('#oauth2link').attr("href", result.message);
$('#oauth2SuccessMsg').show();
$('#smtpOAuthFlowModal').modal('show');
} else {
$('#oauth2ErrorTxt').text("Unable to get the URI to start OAuth2 flow");
$('#oauth2ErrorMsg').show();
$('#smtpOAuthFlowModal').modal('show');
}
},
error: function ($xhr, textStatus, errorThrown) {
$('#spinnerModal').modal('hide');
spinnerDone = true;
let txt = "Unable to get the URI to start OAuth2 flow";
if ($xhr) {
let json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
}
}
}
$('#oauth2ErrorTxt').text(txt);
$('#oauth2ErrorMsg').show();
$('#smtpOAuthFlowModal').modal('show');
}
});
}
function testSMTP(event){
event.preventDefault();
let recipient = $('#idSMTPRecipient').val();
@ -368,11 +512,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
$('#smtpErrorMsg').hide();
showSpinner();
let data = {"host": $('#idSMTPHost').val(),"port": parseInt($('#idSMTPPort').val()),
"from": $('#idSMTPFrom').val(),"user": $('#idSMTPUsername').val(),"password": $('#idSMTPPassword').val(),
"auth_type": parseInt($('#idSMTPAuth').val()),"encryption": parseInt($('#idSMTPEncryption').val()),
"domain": $('#idSMTPDomain').val(),"debug": debug, "oauth2": {"provider": parseInt($('#idSMTPOAuth2Provider').val()),
"tenant": $('#idSMTPOauth2Tenant').val(), "client_id": $('#idSMTPOauth2ClientID').val(),
"client_secret": $('#idSMTPOAuth2ClientSecret').val(), "refresh_token": $('#idSMTPOAuth2RefreshToken').val()},
"recipient": recipient};
$.ajax({
url: "{{.ConfigsURL}}/smtp/test",
type: 'POST',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
data: JSON.stringify({"host": $('#idSMTPHost').val(),"port": parseInt($('#idSMTPPort').val()),"from": $('#idSMTPFrom').val(),"user": $('#idSMTPUsername').val(),"password": $('#idSMTPPassword').val(),"auth_type": parseInt($('#idSMTPAuth').val()),"encryption": parseInt($('#idSMTPEncryption').val()), "domain": $('#idSMTPDomain').val(),"debug": debug, "recipient": recipient}),
data: JSON.stringify(data),
dataType: 'json',
contentType: 'application/json; charset=utf-8',
timeout: 15000,
@ -403,12 +555,32 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
});
}
function onSMTPAuthChanged(val){
if (val == '3'){
$('.smtp-oauth2').show();
onSMTPOAuth2ProviderChanged($('#idSMTPOAuth2Provider').val());
return;
}
$('.smtp-oauth2').hide();
}
function onSMTPOAuth2ProviderChanged(val){
if (val == '1'){
$('.smtp-oauth2-microsoft').show();
return;
}
$('.smtp-oauth2-microsoft').hide();
}
$(document).ready(function () {
$('#spinnerModal').on('shown.bs.modal', function () {
if (spinnerDone){
$('#spinnerModal').modal('hide');
}
});
onSMTPAuthChanged('{{.Configs.SMTP.AuthType}}');
onSMTPOAuth2ProviderChanged('{{.Configs.SMTP.OAuth2.Provider}}');
$('#smtpOauth2ProviderHelpBlock').text('The URI to redirect to after user authentication is '+getCurrentURI()+'{{.OAuth2RedirectURL}}');
});
</script>
{{end}}