mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-24 08:30:27 +00:00
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:
parent
8339fee69d
commit
48939b2b4f
18 changed files with 1329 additions and 115 deletions
|
@ -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
36
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
|
||||
|
|
74
go.sum
74
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=
|
||||
|
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
170
internal/httpd/oauth2.go
Normal 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
|
||||
}
|
135
internal/httpd/oauth2_test.go
Normal file
135
internal/httpd/oauth2_test.go
Normal 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)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
165
internal/smtp/oauth2.go
Normal 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,
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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": []
|
||||
}
|
|
@ -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">×</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}}
|
||||
|
|
Loading…
Reference in a new issue