diff --git a/docs/full-configuration.md b/docs/full-configuration.md index e056a846..4d6ba751 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -453,11 +453,17 @@ The configuration file contains the following sections: - `from`, string. From address, for example `SFTPGo `. 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.
Plugins diff --git a/go.mod b/go.mod index 487c348a..13219de2 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 3b0a16dc..44fde1ed 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/dataprovider/configs.go b/internal/dataprovider/configs.go index 9ffe1854..5cf042f4 100644 --- a/internal/dataprovider/configs.go +++ b/internal/dataprovider/configs.go @@ -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{} } diff --git a/internal/dataprovider/session.go b/internal/dataprovider/session.go index 0ab1007e..b9139ad1 100644 --- a/internal/dataprovider/session.go +++ b/internal/dataprovider/session.go @@ -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 diff --git a/internal/httpd/api_configs.go b/internal/httpd/api_configs.go index 473fd3b0..5010eedc 100644 --- a/internal/httpd/api_configs.go +++ b/internal/httpd/api_configs.go @@ -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) +} diff --git a/internal/httpd/auth_utils.go b/internal/httpd/auth_utils.go index c60d9859..0cf1dcec 100644 --- a/internal/httpd/auth_utils.go +++ b/internal/httpd/auth_utils.go @@ -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") +} diff --git a/internal/httpd/httpd.go b/internal/httpd/httpd.go index 69dac543..a0043e1b 100644 --- a/internal/httpd/httpd.go +++ b/internal/httpd/httpd.go @@ -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() } } } diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index f405a971..04fa93e3 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -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 ", 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) diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go index ed2034c6..2443da78 100644 --- a/internal/httpd/internal_test.go +++ b/internal/httpd/internal_test.go @@ -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", "") diff --git a/internal/httpd/oauth2.go b/internal/httpd/oauth2.go new file mode 100644 index 00000000..d5d0e85c --- /dev/null +++ b/internal/httpd/oauth2.go @@ -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 . + +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 +} diff --git a/internal/httpd/oauth2_test.go b/internal/httpd/oauth2_test.go new file mode 100644 index 00000000..c26d2782 --- /dev/null +++ b/internal/httpd/oauth2_test.go @@ -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 . + +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) +} diff --git a/internal/httpd/server.go b/internal/httpd/server.go index c1e4f3d7..d89eba36 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -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) }) } } diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index 441f2adc..341ada24 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -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 + } +} diff --git a/internal/smtp/oauth2.go b/internal/smtp/oauth2.go new file mode 100644 index 00000000..33dc0732 --- /dev/null +++ b/internal/smtp/oauth2.go @@ -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 . + +// 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, + } +} diff --git a/internal/smtp/smtp.go b/internal/smtp/smtp.go index 6858f717..b0325b72 100644 --- a/internal/smtp/smtp.go +++ b/internal/smtp/smtp.go @@ -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") +} diff --git a/sftpgo.json b/sftpgo.json index 36ddb787..84cca696 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -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": [] } \ No newline at end of file diff --git a/templates/webadmin/configs.html b/templates/webadmin/configs.html index d522ae39..d19d3319 100644 --- a/templates/webadmin/configs.html +++ b/templates/webadmin/configs.html @@ -239,10 +239,11 @@ along with this program. If not, see .
- +
@@ -256,6 +257,59 @@ along with this program. If not, see .
+
+ +
+ + + +
+
+ +
+ +
+ + + Azure Active Directory tenant. Typical values are "common", "organizations", "consumers" or tenant identifier. + +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+
@@ -341,6 +395,30 @@ along with this program. If not, see .
+ + {{end}} {{define "extra_js"}} @@ -351,6 +429,72 @@ along with this program. If not, see . $('#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 . $('#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 . }); } + 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}}'); }); {{end}}