OIDC: add support for custom fields
These fields can be used in the pre-login hook to implement custom logics Fixes #787 Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
parent
87d7854453
commit
cacfffc5bf
15 changed files with 150 additions and 67 deletions
|
@ -61,7 +61,7 @@ Several storage backends are supported: local filesystem, encrypted local filesy
|
|||
|
||||
## Platforms
|
||||
|
||||
SFTPGo is developed and tested on Linux. After each commit, the code is automatically built and tested on Linux, macOS and Windows using a [GitHub Action](./.github/workflows/development.yml). The test cases are regularly manually executed and passed on FreeBSD. Other *BSD variants should work too.
|
||||
SFTPGo is developed and tested on Linux. After each commit, the code is automatically built and tested on Linux, macOS and Windows using [GitHub Actions](./.github/workflows/development.yml). The test cases are regularly manually executed and passed on FreeBSD. Other *BSD variants should work too.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
@ -91,7 +91,7 @@ An official Docker image is available. Documentation is [here](./docker/README.m
|
|||
|
||||
SFTPGo is also available on [AWS Marketplace](https://aws.amazon.com/marketplace/seller-profile?id=6e849ab8-70a6-47de-9a43-13c3fa849335) and [Azure Marketplace](https://azuremarketplace.microsoft.com/en-us/marketplace/apps/prasselsrl1645470739547.sftpgo_linux), purchasing from there will help keep SFTPGo a long-term sustainable project.
|
||||
|
||||
<details><summary>On Windows you can use</summary>
|
||||
<details><summary>Windows packages</summary>
|
||||
|
||||
- The Windows installer to install and run SFTPGo as a Windows service.
|
||||
- The portable package to start SFTPGo on demand.
|
||||
|
@ -177,8 +177,6 @@ Loading data from a provider independent JSON dump is supported from the previou
|
|||
|
||||
## Downgrading
|
||||
|
||||
<details>
|
||||
|
||||
If for some reason you want to downgrade SFTPGo, you may need to downgrade your data provider schema and data as well. You can use the `revertprovider` command for this task.
|
||||
|
||||
As for upgrading, SFTPGo supports downgrading from the previous release branch to the current one.
|
||||
|
@ -199,8 +197,6 @@ The `revertprovider` command is not supported for the memory provider.
|
|||
|
||||
Please note that we only support the current release branch and the current main branch, if you find a bug it is better to report it rather than downgrading to an older unsupported version.
|
||||
|
||||
</details>
|
||||
|
||||
## Users and folders management
|
||||
|
||||
After starting SFTPGo you can manage users and folders using:
|
||||
|
|
|
@ -92,6 +92,7 @@ var (
|
|||
RedirectBaseURL: "",
|
||||
UsernameField: "",
|
||||
RoleField: "",
|
||||
CustomFields: []string{},
|
||||
},
|
||||
Security: httpd.SecurityConf{
|
||||
Enabled: false,
|
||||
|
@ -1283,6 +1284,12 @@ func getHTTPDOIDCFromEnv(idx int) (httpd.OIDC, bool) {
|
|||
isSet = true
|
||||
}
|
||||
|
||||
customFields, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__CUSTOM_FIELDS", idx))
|
||||
if ok {
|
||||
result.CustomFields = customFields
|
||||
isSet = true
|
||||
}
|
||||
|
||||
return result, isSet
|
||||
}
|
||||
|
||||
|
|
|
@ -850,6 +850,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
|
|||
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL", "redirect base url")
|
||||
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD", "preferred_username")
|
||||
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD", "sftpgo_role")
|
||||
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CUSTOM_FIELDS", "field1,field2")
|
||||
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ENABLED", "true")
|
||||
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS", "*.example.com,*.example.net")
|
||||
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS_ARE_REGEX", "1")
|
||||
|
@ -898,6 +899,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
|
|||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CUSTOM_FIELDS")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ENABLED")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS_ARE_REGEX")
|
||||
|
@ -972,6 +974,9 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
|
|||
require.Equal(t, "redirect base url", bindings[2].OIDC.RedirectBaseURL)
|
||||
require.Equal(t, "preferred_username", bindings[2].OIDC.UsernameField)
|
||||
require.Equal(t, "sftpgo_role", bindings[2].OIDC.RoleField)
|
||||
require.Len(t, bindings[2].OIDC.CustomFields, 2)
|
||||
require.Equal(t, "field1", bindings[2].OIDC.CustomFields[0])
|
||||
require.Equal(t, "field2", bindings[2].OIDC.CustomFields[1])
|
||||
require.True(t, bindings[2].Security.Enabled)
|
||||
require.Len(t, bindings[2].Security.AllowedHosts, 2)
|
||||
require.Equal(t, "*.example.com", bindings[2].Security.AllowedHosts[0])
|
||||
|
|
|
@ -977,7 +977,7 @@ func CheckCompositeCredentials(username, password, ip, loginMethod, protocol str
|
|||
} else if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) {
|
||||
user, err = doExternalAuth(username, password, nil, "", ip, protocol, nil)
|
||||
} else if config.PreLoginHook != "" {
|
||||
user, err = executePreLoginHook(username, LoginMethodPassword, ip, protocol)
|
||||
user, err = executePreLoginHook(username, LoginMethodPassword, ip, protocol, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return user, loginMethod, err
|
||||
|
@ -997,7 +997,7 @@ func CheckUserBeforeTLSAuth(username, ip, protocol string, tlsCert *x509.Certifi
|
|||
return doExternalAuth(username, "", nil, "", ip, protocol, tlsCert)
|
||||
}
|
||||
if config.PreLoginHook != "" {
|
||||
return executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol)
|
||||
return executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol, nil)
|
||||
}
|
||||
return UserExists(username)
|
||||
}
|
||||
|
@ -1021,7 +1021,7 @@ func CheckUserAndTLSCert(username, ip, protocol string, tlsCert *x509.Certificat
|
|||
return checkUserAndTLSCertificate(&user, protocol, tlsCert)
|
||||
}
|
||||
if config.PreLoginHook != "" {
|
||||
user, err := executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol)
|
||||
user, err := executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol, nil)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
@ -1048,7 +1048,7 @@ func CheckUserAndPass(username, password, ip, protocol string) (User, error) {
|
|||
return checkUserAndPass(&user, password, ip, protocol)
|
||||
}
|
||||
if config.PreLoginHook != "" {
|
||||
user, err := executePreLoginHook(username, LoginMethodPassword, ip, protocol)
|
||||
user, err := executePreLoginHook(username, LoginMethodPassword, ip, protocol, nil)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
@ -1075,7 +1075,7 @@ func CheckUserAndPubKey(username string, pubKey []byte, ip, protocol string, isS
|
|||
return checkUserAndPubKey(&user, pubKey, isSSHCert)
|
||||
}
|
||||
if config.PreLoginHook != "" {
|
||||
user, err := executePreLoginHook(username, SSHLoginMethodPublicKey, ip, protocol)
|
||||
user, err := executePreLoginHook(username, SSHLoginMethodPublicKey, ip, protocol, nil)
|
||||
if err != nil {
|
||||
return user, "", err
|
||||
}
|
||||
|
@ -1095,7 +1095,7 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard
|
|||
} else if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&4 != 0) {
|
||||
user, err = doExternalAuth(username, "", nil, "1", ip, protocol, nil)
|
||||
} else if config.PreLoginHook != "" {
|
||||
user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive, ip, protocol)
|
||||
user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive, ip, protocol, nil)
|
||||
} else {
|
||||
user, err = provider.userExists(username)
|
||||
}
|
||||
|
@ -1109,9 +1109,9 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard
|
|||
// after a successful authentication with an external identity provider.
|
||||
// If a pre-login hook is defined it will be executed so the SFTPGo user
|
||||
// can be created if it does not exist
|
||||
func GetUserAfterIDPAuth(username, ip, protocol string) (User, error) {
|
||||
func GetUserAfterIDPAuth(username, ip, protocol string, oidcTokenFields *map[string]interface{}) (User, error) {
|
||||
if config.PreLoginHook != "" {
|
||||
return executePreLoginHook(username, LoginMethodIDP, ip, protocol)
|
||||
return executePreLoginHook(username, LoginMethodIDP, ip, protocol, oidcTokenFields)
|
||||
}
|
||||
return UserExists(username)
|
||||
}
|
||||
|
@ -2232,6 +2232,7 @@ func ValidateFolder(folder *vfs.BaseVirtualFolder) error {
|
|||
// ValidateUser returns an error if the user is not valid
|
||||
// FIXME: this should be defined as User struct method
|
||||
func ValidateUser(user *User) error {
|
||||
user.OIDCCustomFields = nil
|
||||
user.SetEmptySecretsIfNil()
|
||||
buildUserHomeDir(user)
|
||||
if err := validateBaseParams(user); err != nil {
|
||||
|
@ -3004,8 +3005,8 @@ func getPreLoginHookResponse(loginMethod, ip, protocol string, userAsJSON []byte
|
|||
return cmd.Output()
|
||||
}
|
||||
|
||||
func executePreLoginHook(username, loginMethod, ip, protocol string) (User, error) {
|
||||
u, userAsJSON, err := getUserAndJSONForHook(username)
|
||||
func executePreLoginHook(username, loginMethod, ip, protocol string, oidcTokenFields *map[string]interface{}) (User, error) {
|
||||
u, userAsJSON, err := getUserAndJSONForHook(username, oidcTokenFields)
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
|
@ -3210,7 +3211,7 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
|
|||
) (User, error) {
|
||||
var user User
|
||||
|
||||
u, userAsJSON, err := getUserAndJSONForHook(username)
|
||||
u, userAsJSON, err := getUserAndJSONForHook(username, nil)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
@ -3290,7 +3291,7 @@ func doPluginAuth(username, password string, pubKey []byte, ip, protocol string,
|
|||
) (User, error) {
|
||||
var user User
|
||||
|
||||
u, userAsJSON, err := getUserAndJSONForHook(username)
|
||||
u, userAsJSON, err := getUserAndJSONForHook(username, nil)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
@ -3355,7 +3356,7 @@ func doPluginAuth(username, password string, pubKey []byte, ip, protocol string,
|
|||
return provider.userExists(user.Username)
|
||||
}
|
||||
|
||||
func getUserAndJSONForHook(username string) (User, []byte, error) {
|
||||
func getUserAndJSONForHook(username string, oidcTokenFields *map[string]interface{}) (User, []byte, error) {
|
||||
var userAsJSON []byte
|
||||
u, err := provider.userExists(username)
|
||||
if err != nil {
|
||||
|
@ -3369,6 +3370,7 @@ func getUserAndJSONForHook(username string) (User, []byte, error) {
|
|||
},
|
||||
}
|
||||
}
|
||||
u.OIDCCustomFields = oidcTokenFields
|
||||
userAsJSON, err = json.Marshal(u)
|
||||
if err != nil {
|
||||
return u, userAsJSON, err
|
||||
|
|
|
@ -254,6 +254,7 @@ The configuration file contains the following sections:
|
|||
- `redirect_base_url`, string. Defines the base URL to redirect to after OpenID authentication. The suffix `/web/oidc/redirect` will be added to this base URL, adding also the `web_root` if configured. Default: blank.
|
||||
- `username_field`, string. Defines the ID token claims field to map to the SFTPGo username. Default: blank.
|
||||
- `role_field`, string. Defines the optional ID token claims field to map to a SFTPGo role. If the defined ID token claims field is set to `admin` the authenticated user is mapped to an SFTPGo admin. You don't need to specify this field if you want to use OpenID only for the Web Client UI. Default: blank.
|
||||
- `custom_fields`, list of strings. Custom token claims fields to pass to the pre-login hook. Default: empty.
|
||||
- `security`, struct. Defines security headers to add to HTTP responses and allows to restrict allowed hosts. The following parameters are supported:
|
||||
- `enabled`, boolean. Set to `true` to enable security configurations. Default: `false`.
|
||||
- `allowed_hosts`, list of strings. Fully qualified domain names that are allowed. An empty list allows any and all host names. Default: empty.
|
||||
|
|
29
docs/oidc.md
29
docs/oidc.md
|
@ -42,7 +42,8 @@ Add the following configuration parameters to the SFTPGo configuration file (or
|
|||
"config_url": "http://192.168.1.12:8086/auth/realms/sftpgo",
|
||||
"redirect_base_url": "http://192.168.1.50:8080",
|
||||
"username_field": "preferred_username",
|
||||
"role_field": "sftpgo_role"
|
||||
"role_field": "sftpgo_role",
|
||||
"custom_fields": []
|
||||
}
|
||||
...
|
||||
```
|
||||
|
@ -99,3 +100,29 @@ And the following is an example ID token which allows the SFTPGo user `user1` to
|
|||
```
|
||||
|
||||
SFTPGo users (not admins) can be created/updated after successful OpenID authentication by defining a [pre-login hook](./dynamic-user-mod.md).
|
||||
You can use the `custom_fields` configuration parameter to define the token claims field names to pass to the pre-login hook, these fields are useful for implementing custom logic when creating/updating the SFTPGo user within the hook.
|
||||
For example you can set the field `sftpgo_home_dir` in your identity provider and add it to the `custom_fields` in the SFTPGo configuration like this:
|
||||
|
||||
```json
|
||||
...
|
||||
"oidc": {
|
||||
"client_id": "sftpgo-client",
|
||||
"client_secret": "jRsmE0SWnuZjP7djBqNq0mrf8QN77j2c",
|
||||
"config_url": "http://192.168.1.12:8086/auth/realms/sftpgo",
|
||||
"redirect_base_url": "http://192.168.1.50:8080",
|
||||
"username_field": "preferred_username",
|
||||
"role_field": "sftpgo_role",
|
||||
"custom_fields": ["sftpgo_home_dir"]
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
The pre-login hook will receive a JSON serialized user with the following field:
|
||||
|
||||
```json
|
||||
...
|
||||
"oidc_custom_fields": {
|
||||
"sftpgo_home_dir": "configured value"
|
||||
},
|
||||
...
|
||||
```
|
||||
|
|
20
go.mod
20
go.mod
|
@ -50,7 +50,7 @@ require (
|
|||
github.com/rs/cors v1.8.2
|
||||
github.com/rs/xid v1.4.0
|
||||
github.com/rs/zerolog v1.26.2-0.20220312163309-e9344a8c507b
|
||||
github.com/sftpgo/sdk v0.1.1-0.20220327080604-3c0f878c8c37
|
||||
github.com/sftpgo/sdk v0.1.1-0.20220412171743-453880fab5f7
|
||||
github.com/shirou/gopsutil/v3 v3.22.3
|
||||
github.com/spf13/afero v1.8.2
|
||||
github.com/spf13/cobra v1.4.0
|
||||
|
@ -64,11 +64,11 @@ require (
|
|||
go.etcd.io/bbolt v1.3.6
|
||||
go.uber.org/automaxprocs v1.5.1
|
||||
gocloud.dev v0.25.0
|
||||
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29
|
||||
golang.org/x/net v0.0.0-20220401154927-543a649e0bdd
|
||||
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a
|
||||
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f
|
||||
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
|
||||
golang.org/x/net v0.0.0-20220412020605-290c469a71a5
|
||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5
|
||||
golang.org/x/sys v0.0.0-20220412071739-889880a91fd5
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306
|
||||
google.golang.org/api v0.74.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0
|
||||
)
|
||||
|
@ -105,7 +105,7 @@ require (
|
|||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/go-cmp v0.5.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.2.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.3.0 // indirect
|
||||
github.com/googleapis/go-type-adapters v1.0.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
|
@ -149,7 +149,7 @@ require (
|
|||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/tools v0.1.10 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac // indirect
|
||||
google.golang.org/grpc v1.45.0 // indirect
|
||||
|
@ -162,6 +162,6 @@ require (
|
|||
|
||||
replace (
|
||||
github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
|
||||
golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220404114519-57edd90b9b42
|
||||
golang.org/x/net => github.com/drakkan/net v0.0.0-20220403065115-db06d28f9f8d
|
||||
golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220412172350-e76a61f8e7d2
|
||||
golang.org/x/net => github.com/drakkan/net v0.0.0-20220412172245-b4d0d6443325
|
||||
)
|
||||
|
|
28
go.sum
28
go.sum
|
@ -232,12 +232,12 @@ github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/
|
|||
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/drakkan/crypto v0.0.0-20220404114519-57edd90b9b42 h1:zEfdtweSYZtEJqrUuezFPsfuvsSdNlbcpi3jlPUQqBc=
|
||||
github.com/drakkan/crypto v0.0.0-20220404114519-57edd90b9b42/go.mod h1:SiM6ypd8Xu1xldObYtbDztuUU7xUzMnUULfphXFZmro=
|
||||
github.com/drakkan/crypto v0.0.0-20220412172350-e76a61f8e7d2 h1:5XmEywX1u5gPgOC+MJuTwLtjMd6gC6t40W+dBgbriSE=
|
||||
github.com/drakkan/crypto v0.0.0-20220412172350-e76a61f8e7d2/go.mod h1:SiM6ypd8Xu1xldObYtbDztuUU7xUzMnUULfphXFZmro=
|
||||
github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
|
||||
github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
|
||||
github.com/drakkan/net v0.0.0-20220403065115-db06d28f9f8d h1:pFFnOn4oQ+0Ewvn4/K41uW6troPfOwMeEHN7RBgIaA4=
|
||||
github.com/drakkan/net v0.0.0-20220403065115-db06d28f9f8d/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
github.com/drakkan/net v0.0.0-20220412172245-b4d0d6443325 h1:yPXArUzpptMAZ95OJHLh4rAG/VMpSqU7R63Tw7Ui22Y=
|
||||
github.com/drakkan/net v0.0.0-20220412172245-b4d0d6443325/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 h1:/ZshrfQzayqRSBDodmp3rhNCHJCff+utvgBuWRbiqu4=
|
||||
github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
|
@ -406,8 +406,9 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
|
|||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
|
||||
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
|
||||
github.com/googleapis/gax-go/v2 v2.2.0 h1:s7jOdKSaksJVOxE0Y/S32otcfiP+UQ0cL8/GTKaONwE=
|
||||
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
|
||||
github.com/googleapis/gax-go/v2 v2.3.0 h1:nRJtk3y8Fm770D42QV6T90ZnvFZyk7agSo3Q+Z9p3WI=
|
||||
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
|
||||
github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
|
||||
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
|
@ -685,8 +686,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
|
|||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
|
||||
github.com/sftpgo/sdk v0.1.1-0.20220327080604-3c0f878c8c37 h1:ESruo35Pb9cCgaGslAmw6leGhzeL0pLzD6o+z9gsZeQ=
|
||||
github.com/sftpgo/sdk v0.1.1-0.20220327080604-3c0f878c8c37/go.mod h1:m5J7DH8unhD5RUsREFRiidP8zgBjup0+iQaxQnYHJOM=
|
||||
github.com/sftpgo/sdk v0.1.1-0.20220412171743-453880fab5f7 h1:y1N2hPOqO1sTCwvtlKWrAiLBLOfThPuE17Tvju1wohs=
|
||||
github.com/sftpgo/sdk v0.1.1-0.20220412171743-453880fab5f7/go.mod h1:m5J7DH8unhD5RUsREFRiidP8zgBjup0+iQaxQnYHJOM=
|
||||
github.com/shirou/gopsutil/v3 v3.22.3 h1:UebRzEomgMpv61e3hgD1tGooqX5trFbdU/ehphbHd00=
|
||||
github.com/shirou/gopsutil/v3 v3.22.3/go.mod h1:D01hZJ4pVHPpCTZ3m3T2+wDF2YAGfd+H4ifUguaQzHM=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
|
@ -838,8 +839,9 @@ golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ
|
|||
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM=
|
||||
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
|
||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
@ -932,8 +934,8 @@ golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f h1:8w7RhxzTVgUzw/AH/9mUV5q0vMgy40SQRursCcfmkCw=
|
||||
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220412071739-889880a91fd5 h1:NubxfvTRuNb4RVzWrIDAUzUvREH1HkCD4JjyQTSG9As=
|
||||
golang.org/x/sys v0.0.0-20220412071739-889880a91fd5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
|
@ -951,8 +953,9 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
|
|||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
|
||||
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
|
@ -1022,8 +1025,9 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T
|
|||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U=
|
||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
|
|
|
@ -511,6 +511,9 @@ func TestBasicUserHandling(t *testing.T) {
|
|||
user.AdditionalInfo = "some free text"
|
||||
user.Filters.TLSUsername = sdk.TLSUsernameCN
|
||||
user.Email = "user@example.net"
|
||||
user.OIDCCustomFields = &map[string]interface{}{
|
||||
"field1": "value1",
|
||||
}
|
||||
user.Filters.WebClient = append(user.Filters.WebClient, sdk.WebClientPubKeyChangeDisabled,
|
||||
sdk.WebClientWriteDisabled)
|
||||
originalUser := user
|
||||
|
@ -520,6 +523,7 @@ func TestBasicUserHandling(t *testing.T) {
|
|||
|
||||
user, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, user.OIDCCustomFields)
|
||||
|
||||
user.Email = "invalid@email"
|
||||
_, body, err := httpdtest.UpdateUser(user, http.StatusBadRequest, "")
|
||||
|
|
|
@ -75,7 +75,9 @@ type OIDC struct {
|
|||
// is mapped to an SFTPGo admin.
|
||||
// You don't need to specify this field if you want to use OpenID only for the
|
||||
// Web Client UI
|
||||
RoleField string `json:"role_field" mapstructure:"role_field"`
|
||||
RoleField string `json:"role_field" mapstructure:"role_field"`
|
||||
// Custom token claims fields to pass to the pre-login hook
|
||||
CustomFields []string `json:"custom_fields" mapstructure:"custom_fields"`
|
||||
provider *oidc.Provider
|
||||
verifier OIDCTokenVerifier
|
||||
providerLogoutURL string
|
||||
|
@ -160,21 +162,22 @@ func newOIDCPendingAuth(audience tokenAudience) oidcPendingAuth {
|
|||
}
|
||||
|
||||
type oidcToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
SessionID string `json:"session_id"`
|
||||
IDToken string `json:"id_token"`
|
||||
Nonce string `json:"nonce"`
|
||||
Username string `json:"username"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Role interface{} `json:"role"`
|
||||
Cookie string `json:"cookie"`
|
||||
UsedAt int64 `json:"used_at"`
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
SessionID string `json:"session_id"`
|
||||
IDToken string `json:"id_token"`
|
||||
Nonce string `json:"nonce"`
|
||||
Username string `json:"username"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Role interface{} `json:"role"`
|
||||
CustomFields *map[string]interface{} `json:"custom_fields,omitempty"`
|
||||
Cookie string `json:"cookie"`
|
||||
UsedAt int64 `json:"used_at"`
|
||||
}
|
||||
|
||||
func (t *oidcToken) parseClaims(claims map[string]interface{}, usernameField, roleField string) error {
|
||||
func (t *oidcToken) parseClaims(claims map[string]interface{}, usernameField, roleField string, customFields []string) error {
|
||||
getClaimsFields := func() []string {
|
||||
keys := make([]string, 0, len(claims))
|
||||
for k := range claims {
|
||||
|
@ -195,6 +198,21 @@ func (t *oidcToken) parseClaims(claims map[string]interface{}, usernameField, ro
|
|||
t.Role = role
|
||||
}
|
||||
}
|
||||
t.CustomFields = nil
|
||||
if len(customFields) > 0 {
|
||||
for _, field := range customFields {
|
||||
if val, ok := claims[field]; ok {
|
||||
if t.CustomFields == nil {
|
||||
customFields := make(map[string]interface{})
|
||||
t.CustomFields = &customFields
|
||||
}
|
||||
logger.Debug(logSender, "", "custom field %#v found in token claims", field)
|
||||
(*t.CustomFields)[field] = val
|
||||
} else {
|
||||
logger.Info(logSender, "", "custom field %#v not found in token claims", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
sid, ok := claims["sid"].(string)
|
||||
if ok {
|
||||
t.SessionID = sid
|
||||
|
@ -300,7 +318,7 @@ func (t *oidcToken) getUser(r *http.Request) error {
|
|||
return nil
|
||||
}
|
||||
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
|
||||
user, err := dataprovider.GetUserAfterIDPAuth(t.Username, ipAddr, common.ProtocolOIDC)
|
||||
user, err := dataprovider.GetUserAfterIDPAuth(t.Username, ipAddr, common.ProtocolOIDC, t.CustomFields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -616,7 +634,7 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
|
|||
if !oauth2Token.Expiry.IsZero() {
|
||||
token.ExpiresAt = util.GetTimeAsMsSinceEpoch(oauth2Token.Expiry)
|
||||
}
|
||||
if err = token.parseClaims(claims, s.binding.OIDC.UsernameField, s.binding.OIDC.RoleField); err != nil {
|
||||
if err = token.parseClaims(claims, s.binding.OIDC.UsernameField, s.binding.OIDC.RoleField, s.binding.OIDC.CustomFields); err != nil {
|
||||
logger.Debug(logSender, "", "unable to parse oidc token claims: %v", err)
|
||||
setFlashMessage(w, r, fmt.Sprintf("Unable to parse OpenID token claims: %v", err))
|
||||
doRedirect()
|
||||
|
|
|
@ -878,7 +878,6 @@ func TestOIDCPreLoginHook(t *testing.T) {
|
|||
u := dataprovider.User{
|
||||
BaseUser: sdk.BaseUser{
|
||||
Username: username,
|
||||
Password: "unused",
|
||||
HomeDir: filepath.Join(os.TempDir(), username),
|
||||
Status: 1,
|
||||
Permissions: map[string][]string{
|
||||
|
@ -897,6 +896,7 @@ func TestOIDCPreLoginHook(t *testing.T) {
|
|||
err = dataprovider.Initialize(newProviderConf, "..", true)
|
||||
assert.NoError(t, err)
|
||||
server := getTestOIDCServer()
|
||||
server.binding.OIDC.CustomFields = []string{"field1", "field2"}
|
||||
err = server.binding.OIDC.initialize()
|
||||
assert.NoError(t, err)
|
||||
server.initializeRouter()
|
||||
|
@ -949,7 +949,7 @@ func TestOIDCPreLoginHook(t *testing.T) {
|
|||
Nonce: authReq.Nonce,
|
||||
Expiry: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
setIDTokenClaims(idToken, []byte(`{"preferred_username":"`+username+`"}`))
|
||||
setIDTokenClaims(idToken, []byte(`{"preferred_username":"`+username+`","field1":"value1","field2":"value2","field3":"value3"}`))
|
||||
server.binding.OIDC.verifier = &mockOIDCVerifier{
|
||||
err: nil,
|
||||
token: idToken,
|
||||
|
@ -989,6 +989,7 @@ func getTestOIDCServer() *httpdServer {
|
|||
RedirectBaseURL: "http://127.0.0.1:8081/",
|
||||
UsernameField: "preferred_username",
|
||||
RoleField: "sftpgo_role",
|
||||
CustomFields: nil,
|
||||
},
|
||||
},
|
||||
enableWebAdmin: true,
|
||||
|
|
|
@ -5070,6 +5070,10 @@ components:
|
|||
additional_info:
|
||||
type: string
|
||||
description: Free form text field for external systems
|
||||
oidc_custom_fields:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: 'This field is passed to the pre-login hook if custom OIDC token fields have been configured. Field values can be of any type (this is a free form object) and depend on the type of the configured OIDC token fields'
|
||||
AdminFilters:
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
@ -237,7 +237,8 @@
|
|||
"config_url": "",
|
||||
"redirect_base_url": "",
|
||||
"username_field": "",
|
||||
"role_field": ""
|
||||
"role_field": "",
|
||||
"custom_fields": []
|
||||
},
|
||||
"security": {
|
||||
"enabled": false,
|
||||
|
|
|
@ -203,14 +203,18 @@ func (fs *CryptFs) ReadDir(dirname string) ([]os.FileInfo, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := f.Readdir(-1)
|
||||
entries, err := f.ReadDir(-1)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]os.FileInfo, 0, len(list))
|
||||
for _, info := range list {
|
||||
result = append(result, fs.ConvertFileInfo(info))
|
||||
result := make([]os.FileInfo, len(entries))
|
||||
for idx, entry := range entries {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[idx] = fs.ConvertFileInfo(info)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
|
13
vfs/osfs.go
13
vfs/osfs.go
|
@ -3,6 +3,7 @@ package vfs
|
|||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
@ -170,12 +171,20 @@ func (*OsFs) ReadDir(dirname string) ([]os.FileInfo, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := f.Readdir(-1)
|
||||
entries, err := f.ReadDir(-1)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
result := make([]fs.FileInfo, len(entries))
|
||||
for idx, entry := range entries {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[idx] = info
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsUploadResumeSupported returns true if resuming uploads is supported
|
||||
|
|
Loading…
Reference in a new issue