diff --git a/common/common.go b/common/common.go index 2ba2cb94..f5f61222 100644 --- a/common/common.go +++ b/common/common.go @@ -83,6 +83,7 @@ const ( ProtocolHTTP = "HTTP" ProtocolHTTPShare = "HTTPShare" ProtocolDataRetention = "DataRetention" + ProtocolOIDC = "OIDC" ) // Upload modes @@ -128,7 +129,7 @@ var ( periodicTimeoutTicker *time.Ticker periodicTimeoutTickerDone chan bool supportedProtocols = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP, ProtocolWebDAV, - ProtocolHTTP, ProtocolHTTPShare} + ProtocolHTTP, ProtocolHTTPShare, ProtocolOIDC} disconnHookProtocols = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP} // the map key is the protocol, for each protocol we can have multiple rate limiters rateLimiters map[string][]*rateLimiter diff --git a/common/connection.go b/common/connection.go index e5b5635d..ee16d453 100644 --- a/common/connection.go +++ b/common/connection.go @@ -1250,7 +1250,7 @@ func (c *BaseConnection) IsNotExistError(err error) bool { switch c.protocol { case ProtocolSFTP: return errors.Is(err, sftp.ErrSSHFxNoSuchFile) - case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolHTTPShare, ProtocolDataRetention: + case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolOIDC, ProtocolHTTPShare, ProtocolDataRetention: return errors.Is(err, os.ErrNotExist) default: return errors.Is(err, ErrNotExist) @@ -1272,7 +1272,7 @@ func (c *BaseConnection) GetPermissionDeniedError() error { switch c.protocol { case ProtocolSFTP: return sftp.ErrSSHFxPermissionDenied - case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolHTTPShare, ProtocolDataRetention: + case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolOIDC, ProtocolHTTPShare, ProtocolDataRetention: return os.ErrPermission default: return ErrPermissionDenied @@ -1284,7 +1284,7 @@ func (c *BaseConnection) GetNotExistError() error { switch c.protocol { case ProtocolSFTP: return sftp.ErrSSHFxNoSuchFile - case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolHTTPShare, ProtocolDataRetention: + case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolOIDC, ProtocolHTTPShare, ProtocolDataRetention: return os.ErrNotExist default: return ErrNotExist diff --git a/common/connection_test.go b/common/connection_test.go index b726347a..826bfc97 100644 --- a/common/connection_test.go +++ b/common/connection_test.go @@ -15,6 +15,7 @@ import ( "github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/kms" + "github.com/drakkan/sftpgo/v2/util" "github.com/drakkan/sftpgo/v2/vfs" ) @@ -262,13 +263,14 @@ func TestUpdateQuotaAfterRename(t *testing.T) { func TestErrorsMapping(t *testing.T) { fs := vfs.NewOsFs("", os.TempDir(), "") conn := NewBaseConnection("", ProtocolSFTP, "", "", dataprovider.User{BaseUser: sdk.BaseUser{HomeDir: os.TempDir()}}) + osErrorsProtocols := []string{ProtocolWebDAV, ProtocolFTP, ProtocolHTTP, ProtocolHTTPShare, + ProtocolDataRetention, ProtocolOIDC} for _, protocol := range supportedProtocols { conn.SetProtocol(protocol) err := conn.GetFsError(fs, os.ErrNotExist) if protocol == ProtocolSFTP { assert.ErrorIs(t, err, sftp.ErrSSHFxNoSuchFile) - } else if protocol == ProtocolWebDAV || protocol == ProtocolFTP || protocol == ProtocolHTTP || - protocol == ProtocolHTTPShare || protocol == ProtocolDataRetention { + } else if util.IsStringInSlice(protocol, osErrorsProtocols) { assert.EqualError(t, err, os.ErrNotExist.Error()) } else { assert.EqualError(t, err, ErrNotExist.Error()) diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 388c4118..eb82b233 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -1050,6 +1050,17 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard return doKeyboardInteractiveAuth(&user, authHook, client, ip, protocol) } +// GetUserAfterIDPAuth returns the SFTPGo user with the specified username +// 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) { + if config.PreLoginHook != "" { + return executePreLoginHook(username, LoginMethodIDP, ip, protocol) + } + return UserExists(username) +} + // GetDefenderHosts returns hosts that are banned or for which some violations have been detected func GetDefenderHosts(from int64, limit int) ([]DefenderEntry, error) { return provider.getDefenderHosts(from, limit) diff --git a/dataprovider/user.go b/dataprovider/user.go index 0e0b69ee..b1d48c4f 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -70,6 +70,7 @@ const ( SSHLoginMethodKeyAndKeyboardInt = "publickey+keyboard-interactive" LoginMethodTLSCertificate = "TLSCertificate" LoginMethodTLSCertificateAndPwd = "TLSCertificate+password" + LoginMethodIDP = "IDP" ) var ( diff --git a/docs/dynamic-user-mod.md b/docs/dynamic-user-mod.md index 2ce2f73d..0e0821af 100644 --- a/docs/dynamic-user-mod.md +++ b/docs/dynamic-user-mod.md @@ -6,9 +6,9 @@ To enable dynamic user modification, you must set the absolute path of your prog The external program can read the following environment variables to get info about the user trying to login: - `SFTPGO_LOGIND_USER`, it contains the user trying to login serialized as JSON. A JSON serialized user id equal to zero means the user does not exist inside SFTPGo -- `SFTPGO_LOGIND_METHOD`, possible values are: `password`, `publickey`, `keyboard-interactive`, `TLSCertificate` +- `SFTPGO_LOGIND_METHOD`, possible values are: `password`, `publickey`, `keyboard-interactive`, `TLSCertificate`, `IDP` (external identity provider) - `SFTPGO_LOGIND_IP`, ip address of the user trying to login -- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP` +- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`, `OIDC` (OpenID Connect) The program must write, on its standard output: @@ -35,6 +35,8 @@ If an error happens while executing the hook then login will be denied. "Dynamic user creation or modification" and "External Authentication" are mutually exclusive, they are quite similar, the difference is that "External Authentication" returns an already authenticated user while using "Dynamic users modification" you simply create or update a user. The authentication will be checked inside SFTPGo. In other words while using "External Authentication" the external program receives the credentials of the user trying to login (for example the cleartext password) and it needs to validate them. While using "Dynamic users modification" the pre-login program receives the user stored inside the dataprovider (it includes the hashed password if any) and it can modify it, after the modification SFTPGo will check the credentials of the user trying to login. +For SFTPGo users (not admins) authenticating using an external identity provider such as OpenID Connect, the pre-login hook will be executed after a successful authentication against the external IDP so that you can create/update the SFTPGo user matching the one authenticated against the identity provider. This is the only case where the pre-login hook is executed even if an external authentication hook is defined. + You can disable the hook on a per-user basis. Let's see a very basic example. Our sample program will grant access to the existing user `test_user` only in the time range 10:00-18:00. Other users will not be modified since the program will terminate with no output. diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 2186e066..d266988e 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -252,7 +252,7 @@ The configuration file contains the following sections: - `sts_seconds`, integer. Defines the max-age of the `Strict-Transport-Security` header. This header will be included for `https` responses or for HTTP request if the request includes a defined HTTPS proxy header. Default: `0`, which would NOT include the header. - `sts_include_subdomains`, boolean. Set to `true`, the `includeSubdomains` will be appended to the `Strict-Transport-Security` header. Default: `false`. - `sts_preload`, boolean. Set to true, the `preload` flag will be appended to the `Strict-Transport-Security` header. Default: `false`. - - `content_type_nosniff`, boolean. Set to `true` to add the `X-Content-Type-Options` header with the value `nosniff` Default: `false`. + - `content_type_nosniff`, boolean. Set to `true` to add the `X-Content-Type-Options` header with the value `nosniff`. Default: `false`. - `content_security_policy`, string. Allows to set the `Content-Security-Policy` header value. Default: blank. - `permissions_policy`, string. Allows to set the `Permissions-Policy` header value. Default: blank. - `cross_origin_opener_policy`, string. Allows to set the `Cross-Origin-Opener-Policy` header value. Default: blank. diff --git a/docs/oidc.md b/docs/oidc.md index 383b10a2..3e3b20fa 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -97,3 +97,5 @@ And the following is an example ID token which allows the SFTPGo user `user1` to "preferred_username": "user1" } ``` + +SFTPGo users (not admins) can be created/updated after successful OpenID authentication by defining a [pre-login hook](./dynamic-user-mod.md). diff --git a/docs/post-connect-hook.md b/docs/post-connect-hook.md index d33fd400..cfac34e0 100644 --- a/docs/post-connect-hook.md +++ b/docs/post-connect-hook.md @@ -9,7 +9,7 @@ The `post_connect_hook` can be defined as the absolute path of your program or a If the hook defines an external program it can read the following environment variables: - `SFTPGO_CONNECTION_IP` -- `SFTPGO_CONNECTION_PROTOCOL` +- `SFTPGO_CONNECTION_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`, `OIDC` (OpenID Connect) If the external command completes with a zero exit status the connection will be accepted otherwise rejected. @@ -19,7 +19,7 @@ The program must finish within 20 seconds. If the hook defines an HTTP URL then this URL will be invoked as HTTP GET with the following query parameters: - `ip` -- `protocol` +- `protocol`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`, `OIDC` (OpenID Connect) The connection is accepted if the HTTP response code is `200` otherwise rejected. diff --git a/docs/post-disconnect-hook.md b/docs/post-disconnect-hook.md index b71decae..6f799a1d 100644 --- a/docs/post-disconnect-hook.md +++ b/docs/post-disconnect-hook.md @@ -9,7 +9,7 @@ The `post_disconnect_hook` can be defined as the absolute path of your program o If the hook defines an external program it can read the following environment variables: - `SFTPGO_CONNECTION_IP` -- `SFTPGO_CONNECTION_PROTOCOL` +- `SFTPGO_CONNECTION_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`, `OIDC` (OpenID Connect) - `SFTPGO_CONNECTION_USERNAME`, can be empty if the channel is closed before user authentication - `SFTPGO_CONNECTION_DURATION`, connection duration in milliseconds @@ -19,7 +19,7 @@ The program must finish within 20 seconds. If the hook defines an HTTP URL then this URL will be invoked as HTTP GET with the following query parameters: - `ip` -- `protocol` +- `protocol`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`, `OIDC` (OpenID Connect) - `username`, can be empty if the channel is closed before user authentication - `connection_duration`, connection duration in milliseconds diff --git a/docs/post-login-hook.md b/docs/post-login-hook.md index fd6432f9..5fe60f4d 100644 --- a/docs/post-login-hook.md +++ b/docs/post-login-hook.md @@ -10,9 +10,9 @@ If the hook defines an external program it can reads the following environment v - `SFTPGO_LOGIND_USER`, it contains the user serialized as JSON. The username is empty if the connection is closed for authentication timeout - `SFTPGO_LOGIND_IP` -- `SFTPGO_LOGIND_METHOD`, possible values are `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive`, `TLSCertificate`, `TLSCertificate+password` or `no_auth_tryed` +- `SFTPGO_LOGIND_METHOD`, possible values are `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive`, `TLSCertificate`, `TLSCertificate+password` or `no_auth_tryed`, `IDP` (external identity provider) - `SFTPGO_LOGIND_STATUS`, 1 means login OK, 0 login KO -- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP` +- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`, `OIDC` (OpenID Connect) Previous global environment variables aren't cleared when the script is called. The program must finish within 20 seconds. diff --git a/go.mod b/go.mod index 267fcd8d..48f5dcb5 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,14 @@ module github.com/drakkan/sftpgo/v2 go 1.17 require ( - cloud.google.com/go/storage v1.20.0 + cloud.google.com/go/storage v1.21.0 github.com/Azure/azure-storage-blob-go v0.14.0 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 - github.com/aws/aws-sdk-go v1.43.0 + github.com/aws/aws-sdk-go v1.43.2 github.com/cockroachdb/cockroach-go/v2 v2.2.8 github.com/coreos/go-oidc/v3 v3.1.0 - github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b + github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 github.com/fclairamb/ftpserverlib v0.17.1-0.20220212161409-5157f18d716f github.com/fclairamb/go-log v0.2.0 github.com/go-chi/chi/v5 v5.0.8-0.20220103230436-7dbe9a0bd10f @@ -128,7 +128,7 @@ require ( golang.org/x/tools v0.1.9 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220217155828-d576998c0009 // indirect + google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c // indirect google.golang.org/grpc v1.44.0 // indirect google.golang.org/protobuf v1.27.1 // indirect gopkg.in/ini.v1 v1.66.4 // indirect @@ -138,7 +138,6 @@ require ( ) replace ( - github.com/eikenb/pipeat => github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639 github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220215181150-74469fa99b22 golang.org/x/net => github.com/drakkan/net v0.0.0-20220130095023-bd85f1236c34 diff --git a/go.sum b/go.sum index df00ff06..7fa660eb 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= cloud.google.com/go/storage v1.16.1/go.mod h1:LaNorbty3ehnU3rEjXSNV/NRgQA0O8Y+uh6bPe5UOk4= -cloud.google.com/go/storage v1.20.0 h1:kv3rQ3clEQdxqokkCCgQo+bxPqcuXiROjxvnKb8Oqdk= -cloud.google.com/go/storage v1.20.0/go.mod h1:TiC1o6FxNCG8y5gB7rqCsFZCIYPMPZCO81ppOoEPLGI= +cloud.google.com/go/storage v1.21.0 h1:HwnT2u2D309SFDHQII6m18HlrCi3jAXhUMTLOWXYH14= +cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA= cloud.google.com/go/trace v0.1.0/go.mod h1:wxEwsoeRVPbeSkt7ZC9nWCgmoKQRAoySN7XHW2AmI7g= contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA= contrib.go.opencensus.io/exporter/stackdriver v0.13.8/go.mod h1:huNtlWx75MwO7qMs0KrMxPZXzNNWebav1Sq/pm02JdQ= @@ -143,8 +143,8 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= -github.com/aws/aws-sdk-go v1.43.0 h1:y4UrPbxU/mIL08qksVPE/nwH9IXuC1udjOaNyhEe+pI= -github.com/aws/aws-sdk-go v1.43.0/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc= +github.com/aws/aws-sdk-go v1.43.2 h1:T6LuKCNu8CYXXDn3xJoldh8FbdvuVH7C9aSuLNrlht0= +github.com/aws/aws-sdk-go v1.43.2/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc= github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY= github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY= @@ -224,8 +224,8 @@ github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHP github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU= github.com/drakkan/net v0.0.0-20220130095023-bd85f1236c34 h1:DRayAKtBRaVU3jg58b/HCbkRleByBD5q6NkN1wcJ2RU= github.com/drakkan/net v0.0.0-20220130095023-bd85f1236c34/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639 h1:8tfGdb4kg/YCvAbIrsMazgoNtnqdOqQVDKW12uUCuuU= -github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84= +github.com/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= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -1177,8 +1177,9 @@ google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220217155828-d576998c0009 h1:8QEZX8dJDqdCxQVLRWzEKGOkOzuDx0AU4+bQX6LwmU4= -google.golang.org/genproto v0.0.0-20220217155828-d576998c0009/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c h1:TU4rFa5APdKTq0s6B7WTsH6Xmx0Knj86s6Biz56mErE= +google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= diff --git a/httpd/api_http_user.go b/httpd/api_http_user.go index 0c449a6a..8021706c 100644 --- a/httpd/api_http_user.go +++ b/httpd/api_http_user.go @@ -32,13 +32,14 @@ func getUserConnection(w http.ResponseWriter, r *http.Request) (*Connection, err return nil, err } connID := xid.New().String() - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) + protocol := getProtocolFromRequest(r) + connectionID := fmt.Sprintf("%v_%v", protocol, connID) if err := checkHTTPClientUser(&user, r, connectionID); err != nil { sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden) return nil, err } connection := &Connection{ - BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, util.GetHTTPLocalAddress(r), + BaseConnection: common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r), r.RemoteAddr, user), request: r, } @@ -552,7 +553,7 @@ func doChangeUserPassword(r *http.Request, currentPassword, newPassword, confirm return errors.New("invalid token claims") } user, err := dataprovider.CheckUserAndPass(claims.Username, currentPassword, util.GetIPFromRemoteAddress(r.RemoteAddr), - common.ProtocolHTTP) + getProtocolFromRequest(r)) if err != nil { return util.NewValidationError("current password does not match") } diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 5e9fea13..478ec305 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -477,18 +477,25 @@ func parseRangeRequest(bytesRange string, size int64) (int64, int64, error) { return start, size, err } -func updateLoginMetrics(user *dataprovider.User, ip string, err error) { - metric.AddLoginAttempt(dataprovider.LoginMethodPassword) +func updateLoginMetrics(user *dataprovider.User, loginMethod, ip string, err error) { + metric.AddLoginAttempt(loginMethod) + var protocol string + switch loginMethod { + case dataprovider.LoginMethodIDP: + protocol = common.ProtocolOIDC + default: + protocol = common.ProtocolHTTP + } if err != nil && err != common.ErrInternalFailure && err != common.ErrNoCredentials { - logger.ConnectionFailedLog(user.Username, ip, dataprovider.LoginMethodPassword, common.ProtocolHTTP, err.Error()) + logger.ConnectionFailedLog(user.Username, ip, loginMethod, protocol, err.Error()) event := common.HostEventLoginFailed if _, ok := err.(*util.RecordNotFoundError); ok { event = common.HostEventUserNotFound } common.AddDefenderEvent(ip, event) } - metric.AddLoginResult(dataprovider.LoginMethodPassword, err) - dataprovider.ExecutePostLoginHook(user, dataprovider.LoginMethodPassword, ip, common.ProtocolHTTP, err) + metric.AddLoginResult(loginMethod, err) + dataprovider.ExecutePostLoginHook(user, loginMethod, ip, protocol, err) } func checkHTTPClientUser(user *dataprovider.User, r *http.Request, connectionID string) error { @@ -496,7 +503,7 @@ func checkHTTPClientUser(user *dataprovider.User, r *http.Request, connectionID logger.Info(logSender, connectionID, "cannot login user %#v, protocol HTTP is not allowed", user.Username) return fmt.Errorf("protocol HTTP is not allowed for user %#v", user.Username) } - if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) { + if !isLoggedInWithOIDC(r) && !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) { logger.Info(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username) return fmt.Errorf("login method password is not allowed for user %#v", user.Username) } @@ -635,3 +642,10 @@ func isUserAllowedToResetPassword(r *http.Request, user *dataprovider.User) bool } return true } + +func getProtocolFromRequest(r *http.Request) string { + if isLoggedInWithOIDC(r) { + return common.ProtocolOIDC + } + return common.ProtocolHTTP +} diff --git a/httpd/middleware.go b/httpd/middleware.go index ab7798cd..77e88b2c 100644 --- a/httpd/middleware.go +++ b/httpd/middleware.go @@ -376,31 +376,34 @@ func authenticateAdminWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTA func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAuth, r *http.Request) error { ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) + protocol := common.ProtocolHTTP if username == "" { err := errors.New("the provided key is not associated with any user and no username was provided") - updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, ipAddr, err) + updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, + dataprovider.LoginMethodPassword, ipAddr, err) return err } - if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil { + if err := common.Config.ExecutePostConnectHook(ipAddr, protocol); err != nil { return err } user, err := dataprovider.UserExists(username) if err != nil { - updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, ipAddr, err) + updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, + dataprovider.LoginMethodPassword, ipAddr, err) return err } if !user.Filters.AllowAPIKeyAuth { err := fmt.Errorf("API key authentication disabled for user %#v", user.Username) - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err) return err } if err := user.CheckLoginConditions(); err != nil { - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err) return err } - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String()) + connectionID := fmt.Sprintf("%v_%v", protocol, xid.New().String()) if err := checkHTTPClientUser(&user, r, connectionID); err != nil { - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err) return err } lastLogin := util.GetTimeFromMsecSinceEpoch(user.LastLogin) @@ -409,7 +412,7 @@ func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAu defer user.CloseFs() //nolint:errcheck err = user.CheckFsRoot(connectionID) if err != nil { - updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure) return common.ErrInternalFailure } } @@ -422,12 +425,12 @@ func authenticateUserWithAPIKey(username, keyID string, tokenAuth *jwtauth.JWTAu resp, err := c.createTokenResponse(tokenAuth, tokenAudienceAPIUser) if err != nil { - updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure) return err } r.Header.Set("Authorization", fmt.Sprintf("Bearer %v", resp["access_token"])) dataprovider.UpdateLastLogin(&user) - updateLoginMetrics(&user, ipAddr, nil) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, nil) return nil } diff --git a/httpd/oidc.go b/httpd/oidc.go index 8435e48a..62cf5264 100644 --- a/httpd/oidc.go +++ b/httpd/oidc.go @@ -278,32 +278,32 @@ func (t *oidcToken) getUser(r *http.Request) error { dataprovider.UpdateAdminLastLogin(&admin) return nil } - user, err := dataprovider.UserExists(t.Username) + ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) + user, err := dataprovider.GetUserAfterIDPAuth(t.Username, ipAddr, common.ProtocolOIDC) if err != nil { return err } - ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) - if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil { - updateLoginMetrics(&user, ipAddr, err) + if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolOIDC); err != nil { + updateLoginMetrics(&user, dataprovider.LoginMethodIDP, ipAddr, err) return fmt.Errorf("access denied by post connect hook: %w", err) } if err := user.CheckLoginConditions(); err != nil { - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, dataprovider.LoginMethodIDP, ipAddr, err) return err } - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String()) + connectionID := fmt.Sprintf("%v_%v", common.ProtocolOIDC, xid.New().String()) if err := checkHTTPClientUser(&user, r, connectionID); err != nil { - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, dataprovider.LoginMethodIDP, ipAddr, err) return err } defer user.CloseFs() //nolint:errcheck err = user.CheckFsRoot(connectionID) if err != nil { logger.Warn(logSender, connectionID, "unable to check fs root: %v", err) - updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) + updateLoginMetrics(&user, dataprovider.LoginMethodIDP, ipAddr, common.ErrInternalFailure) return err } - updateLoginMetrics(&user, ipAddr, nil) + updateLoginMetrics(&user, dataprovider.LoginMethodIDP, ipAddr, nil) dataprovider.UpdateLastLogin(&user) t.Permissions = user.Filters.WebClient return nil diff --git a/httpd/oidc_test.go b/httpd/oidc_test.go index 39cba0f5..4d38f08a 100644 --- a/httpd/oidc_test.go +++ b/httpd/oidc_test.go @@ -2,12 +2,14 @@ package httpd import ( "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" + "runtime" "testing" "time" "unsafe" @@ -782,23 +784,6 @@ func TestOIDCToken(t *testing.T) { assert.NoError(t, err) } -func getTestOIDCServer() *httpdServer { - return &httpdServer{ - binding: Binding{ - OIDC: OIDC{ - ClientID: "sftpgo-client", - ClientSecret: "jRsmE0SWnuZjP7djBqNq0mrf8QN77j2c", - ConfigURL: fmt.Sprintf("http://%v/auth/realms/sftpgo", oidcMockAddr), - RedirectBaseURL: "http://127.0.0.1:8081/", - UsernameField: "preferred_username", - RoleField: "sftpgo_role", - }, - }, - enableWebAdmin: true, - enableWebClient: true, - } -} - func TestOIDCManager(t *testing.T) { require.Len(t, oidcMgr.pendingAuths, 0) authReq := newOIDCPendingAuth(tokenAudienceWebAdmin) @@ -881,3 +866,142 @@ func TestOIDCManager(t *testing.T) { oidcMgr.removeToken(newToken.Cookie) require.Len(t, oidcMgr.tokens, 0) } + +func TestOIDCPreLoginHook(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("this test is not available on Windows") + } + username := "test_oidc_user_prelogin" + u := dataprovider.User{ + BaseUser: sdk.BaseUser{ + Username: username, + Password: "unused", + HomeDir: filepath.Join(os.TempDir(), username), + Status: 1, + Permissions: map[string][]string{ + "/": {dataprovider.PermAny}, + }, + }, + } + preLoginPath := filepath.Join(os.TempDir(), "prelogin.sh") + providerConf := dataprovider.GetProviderConfig() + err := dataprovider.Close() + assert.NoError(t, err) + err = os.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm) + assert.NoError(t, err) + newProviderConf := providerConf + newProviderConf.PreLoginHook = preLoginPath + err = dataprovider.Initialize(newProviderConf, "..", true) + assert.NoError(t, err) + server := getTestOIDCServer() + err = server.binding.OIDC.initialize() + assert.NoError(t, err) + server.initializeRouter() + + _, err = dataprovider.UserExists(username) + _, ok := err.(*util.RecordNotFoundError) + assert.True(t, ok) + // now login with OIDC + authReq := newOIDCPendingAuth(tokenAudienceWebClient) + oidcMgr.addPendingAuth(authReq) + token := &oauth2.Token{ + AccessToken: "1234", + Expiry: time.Now().Add(5 * time.Minute), + } + token = token.WithExtra(map[string]interface{}{ + "id_token": "id_token_val", + }) + server.binding.OIDC.oauth2Config = &mockOAuth2Config{ + tokenSource: &mockTokenSource{}, + authCodeURL: webOIDCRedirectPath, + token: token, + } + idToken := &oidc.IDToken{ + Nonce: authReq.Nonce, + Expiry: time.Now().Add(5 * time.Minute), + } + setIDTokenClaims(idToken, []byte(`{"preferred_username":"`+username+`"}`)) + server.binding.OIDC.verifier = &mockOIDCVerifier{ + err: nil, + token: idToken, + } + rr := httptest.NewRecorder() + r, err := http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil) + assert.NoError(t, err) + server.router.ServeHTTP(rr, r) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webClientFilesPath, rr.Header().Get("Location")) + _, err = dataprovider.UserExists(username) + assert.NoError(t, err) + + err = dataprovider.DeleteUser(username, "", "") + assert.NoError(t, err) + + err = os.WriteFile(preLoginPath, getPreLoginScriptContent(u, true), os.ModePerm) + assert.NoError(t, err) + + authReq = newOIDCPendingAuth(tokenAudienceWebClient) + oidcMgr.addPendingAuth(authReq) + idToken = &oidc.IDToken{ + Nonce: authReq.Nonce, + Expiry: time.Now().Add(5 * time.Minute), + } + setIDTokenClaims(idToken, []byte(`{"preferred_username":"`+username+`"}`)) + server.binding.OIDC.verifier = &mockOIDCVerifier{ + err: nil, + token: idToken, + } + rr = httptest.NewRecorder() + r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil) + assert.NoError(t, err) + server.router.ServeHTTP(rr, r) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) + _, err = dataprovider.UserExists(username) + _, ok = err.(*util.RecordNotFoundError) + assert.True(t, ok) + if assert.Len(t, oidcMgr.tokens, 1) { + for k := range oidcMgr.tokens { + oidcMgr.removeToken(k) + } + } + require.Len(t, oidcMgr.pendingAuths, 0) + require.Len(t, oidcMgr.tokens, 0) + + err = dataprovider.Close() + assert.NoError(t, err) + err = dataprovider.Initialize(providerConf, "..", true) + assert.NoError(t, err) + err = os.Remove(preLoginPath) + assert.NoError(t, err) +} + +func getTestOIDCServer() *httpdServer { + return &httpdServer{ + binding: Binding{ + OIDC: OIDC{ + ClientID: "sftpgo-client", + ClientSecret: "jRsmE0SWnuZjP7djBqNq0mrf8QN77j2c", + ConfigURL: fmt.Sprintf("http://%v/auth/realms/sftpgo", oidcMockAddr), + RedirectBaseURL: "http://127.0.0.1:8081/", + UsernameField: "preferred_username", + RoleField: "sftpgo_role", + }, + }, + enableWebAdmin: true, + enableWebClient: true, + } +} + +func getPreLoginScriptContent(user dataprovider.User, nonJSONResponse bool) []byte { + content := []byte("#!/bin/sh\n\n") + if nonJSONResponse { + content = append(content, []byte("echo 'text response'\n")...) + return content + } + if len(user.Username) > 0 { + u, _ := json.Marshal(user) + content = append(content, []byte(fmt.Sprintf("echo '%v'\n", string(u)))...) + } + return content +} diff --git a/httpd/server.go b/httpd/server.go index 6518fd23..06007fca 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -199,33 +199,36 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re return } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) + protocol := common.ProtocolHTTP username := r.Form.Get("username") password := r.Form.Get("password") if username == "" || password == "" { - updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, ipAddr, common.ErrNoCredentials) + updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, + dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials) s.renderClientLoginPage(w, "Invalid credentials") return } if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, ipAddr, err) + updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, + dataprovider.LoginMethodPassword, ipAddr, err) s.renderClientLoginPage(w, err.Error()) return } - if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil { + if err := common.Config.ExecutePostConnectHook(ipAddr, protocol); err != nil { s.renderClientLoginPage(w, fmt.Sprintf("access denied by post connect hook: %v", err)) return } - user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, common.ProtocolHTTP) + user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, protocol) if err != nil { - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err) s.renderClientLoginPage(w, dataprovider.ErrInvalidCredentials.Error()) return } - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String()) + connectionID := fmt.Sprintf("%v_%v", protocol, xid.New().String()) if err := checkHTTPClientUser(&user, r, connectionID); err != nil { - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err) s.renderClientLoginPage(w, err.Error()) return } @@ -234,7 +237,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re err = user.CheckFsRoot(connectionID) if err != nil { logger.Warn(logSender, connectionID, "unable to check fs root: %v", err) - updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure) s.renderClientLoginPage(w, err.Error()) return } @@ -261,7 +264,7 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r renderClientResetPwdPage(w, err.Error()) return } - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String()) + connectionID := fmt.Sprintf("%v_%v", getProtocolFromRequest(r), xid.New().String()) if err := checkHTTPClientUser(user, r, connectionID); err != nil { renderClientResetPwdPage(w, fmt.Sprintf("Password reset successfully but unable to login: %v", err.Error())) return @@ -325,7 +328,7 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter renderClientInternalServerErrorPage(w, r, errors.New("unable to set the recovery code as used")) return } - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String()) + connectionID := fmt.Sprintf("%v_%v", getProtocolFromRequest(r), xid.New().String()) s.loginUser(w, r, &user, connectionID, util.GetIPFromRemoteAddress(r.RemoteAddr), true, renderClientTwoFactorRecoveryPage) return @@ -375,7 +378,7 @@ func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *htt renderClientTwoFactorPage(w, "Invalid authentication code") return } - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String()) + connectionID := fmt.Sprintf("%v_%v", getProtocolFromRequest(r), xid.New().String()) s.loginUser(w, r, &user, connectionID, util.GetIPFromRemoteAddress(r.RemoteAddr), true, renderClientTwoFactorPage) } @@ -646,7 +649,7 @@ func (s *httpdServer) loginUser( err := c.createAndSetCookie(w, r, s.tokenAuth, audience) if err != nil { logger.Warn(logSender, connectionID, "unable to set user login cookie %v", err) - updateLoginMetrics(user, ipAddr, common.ErrInternalFailure) + updateLoginMetrics(user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure) errorFunc(w, err.Error()) return } @@ -657,7 +660,7 @@ func (s *httpdServer) loginUser( http.Redirect(w, r, webClientTwoFactorPath, http.StatusFound) return } - updateLoginMetrics(user, ipAddr, err) + updateLoginMetrics(user, dataprovider.LoginMethodPassword, ipAddr, err) dataprovider.UpdateLastLogin(user) http.Redirect(w, r, webClientFilesPath, http.StatusFound) } @@ -708,33 +711,36 @@ func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize) ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) username, password, ok := r.BasicAuth() + protocol := common.ProtocolHTTP if !ok { - updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, ipAddr, common.ErrNoCredentials) + updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, + dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials) w.Header().Set(common.HTTPAuthenticationHeader, basicRealm) sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } if username == "" || password == "" { - updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, ipAddr, common.ErrNoCredentials) + updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, + dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials) w.Header().Set(common.HTTPAuthenticationHeader, basicRealm) sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } - if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil { + if err := common.Config.ExecutePostConnectHook(ipAddr, protocol); err != nil { sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden) return } - user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, common.ProtocolHTTP) + user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, protocol) if err != nil { w.Header().Set(common.HTTPAuthenticationHeader, basicRealm) - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err) sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String()) + connectionID := fmt.Sprintf("%v_%v", protocol, xid.New().String()) if err := checkHTTPClientUser(&user, r, connectionID); err != nil { - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err) sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden) return } @@ -744,14 +750,14 @@ func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) { if passcode == "" { logger.Debug(logSender, "", "TOTP enabled for user %#v and not passcode provided, authentication refused", user.Username) w.Header().Set(common.HTTPAuthenticationHeader, basicRealm) - updateLoginMetrics(&user, ipAddr, dataprovider.ErrInvalidCredentials) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, dataprovider.ErrInvalidCredentials) sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } err = user.Filters.TOTPConfig.Secret.Decrypt() if err != nil { - updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure) sendAPIResponse(w, r, fmt.Errorf("unable to decrypt TOTP secret: %w", err), http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -760,7 +766,7 @@ func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) { if !match || err != nil { logger.Debug(logSender, "invalid passcode for user %#v, match? %v, err: %v", user.Username, match, err) w.Header().Set(common.HTTPAuthenticationHeader, basicRealm) - updateLoginMetrics(&user, ipAddr, dataprovider.ErrInvalidCredentials) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, dataprovider.ErrInvalidCredentials) sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return @@ -771,7 +777,7 @@ func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) { err = user.CheckFsRoot(connectionID) if err != nil { logger.Warn(logSender, connectionID, "unable to check fs root: %v", err) - updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure) sendAPIResponse(w, r, err, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -789,11 +795,11 @@ func (s *httpdServer) generateAndSendUserToken(w http.ResponseWriter, r *http.Re resp, err := c.createTokenResponse(s.tokenAuth, tokenAudienceAPIUser) if err != nil { - updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure) sendAPIResponse(w, r, err, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err) dataprovider.UpdateLastLogin(&user) render.JSON(w, r, resp) diff --git a/httpd/webclient.go b/httpd/webclient.go index eda47600..574811d9 100644 --- a/httpd/webclient.go +++ b/httpd/webclient.go @@ -584,13 +584,14 @@ func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) { } connID := xid.New().String() - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) + protocol := getProtocolFromRequest(r) + connectionID := fmt.Sprintf("%v_%v", protocol, connID) if err := checkHTTPClientUser(&user, r, connectionID); err != nil { renderClientForbiddenPage(w, r, err.Error()) return } connection := &Connection{ - BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, util.GetHTTPLocalAddress(r), + BaseConnection: common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r), r.RemoteAddr, user), request: r, } @@ -727,13 +728,14 @@ func (s *httpdServer) handleClientGetDirContents(w http.ResponseWriter, r *http. } connID := xid.New().String() - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) + protocol := getProtocolFromRequest(r) + connectionID := fmt.Sprintf("%v_%v", protocol, connID) if err := checkHTTPClientUser(&user, r, connectionID); err != nil { sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden) return } connection := &Connection{ - BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, util.GetHTTPLocalAddress(r), + BaseConnection: common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r), r.RemoteAddr, user), request: r, } @@ -804,13 +806,14 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques } connID := xid.New().String() - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) + protocol := getProtocolFromRequest(r) + connectionID := fmt.Sprintf("%v_%v", protocol, connID) if err := checkHTTPClientUser(&user, r, connectionID); err != nil { renderClientForbiddenPage(w, r, err.Error()) return } connection := &Connection{ - BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, util.GetHTTPLocalAddress(r), + BaseConnection: common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r), r.RemoteAddr, user), request: r, } @@ -863,13 +866,14 @@ func handleClientEditFile(w http.ResponseWriter, r *http.Request) { } connID := xid.New().String() - connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) + protocol := getProtocolFromRequest(r) + connectionID := fmt.Sprintf("%v_%v", protocol, connID) if err := checkHTTPClientUser(&user, r, connectionID); err != nil { renderClientForbiddenPage(w, r, err.Error()) return } connection := &Connection{ - BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, util.GetHTTPLocalAddress(r), + BaseConnection: common.NewBaseConnection(connID, protocol, util.GetHTTPLocalAddress(r), r.RemoteAddr, user), request: r, } diff --git a/metric/metric.go b/metric/metric.go index 16cd3fc2..3742cdc8 100644 --- a/metric/metric.go +++ b/metric/metric.go @@ -20,6 +20,7 @@ const ( loginMethodKeyAndKeyboardInt = "publickey+keyboard-interactive" loginMethodTLSCertificate = "TLSCertificate" loginMethodTLSCertificateAndPwd = "TLSCertificate+password" + loginMethodIDP = "IDP" ) func init() { @@ -259,6 +260,27 @@ var ( Help: "The total number of failed logins using public key + keyboard interactive", }) + // totalIDPLoginAttempts is the metric that reports the total number of + // login attempts using identity providers + totalIDPLoginAttempts = promauto.NewCounter(prometheus.CounterOpts{ + Name: "sftpgo_idp_login_attempts_total", + Help: "The total number of login attempts using Identity Providers", + }) + + // totalIDPLoginOK is the metric that reports the total number of + // successful logins using identity providers + totalIDPLoginOK = promauto.NewCounter(prometheus.CounterOpts{ + Name: "sftpgo_idp_login_ok_total", + Help: "The total number of successful logins using Identity Providers", + }) + + // totalIDPLoginFailed is the metric that reports the total number of + // failed logins using identity providers + totalIDPLoginFailed = promauto.NewCounter(prometheus.CounterOpts{ + Name: "sftpgo_idp_login_ko_total", + Help: "The total number of failed logins using Identity Providers", + }) + totalHTTPRequests = promauto.NewCounter(prometheus.CounterOpts{ Name: "sftpgo_http_req_total", Help: "The total number of HTTP requests served", @@ -582,7 +604,6 @@ func TransferCompleted(bytesSent, bytesReceived int64, transferKind int, err err } else { totalUploadErrors.Inc() } - totalUploadSize.Add(float64(bytesReceived)) } else { // download if err == nil { @@ -590,6 +611,11 @@ func TransferCompleted(bytesSent, bytesReceived int64, transferKind int, err err } else { totalDownloadErrors.Inc() } + } + if bytesReceived > 0 { + totalUploadSize.Add(float64(bytesReceived)) + } + if bytesSent > 0 { totalDownloadSize.Add(float64(bytesSent)) } } @@ -826,6 +852,8 @@ func AddLoginAttempt(authMethod string) { totalTLSCertLoginAttempts.Inc() case loginMethodTLSCertificateAndPwd: totalTLSCertAndPwdLoginAttempts.Inc() + case loginMethodIDP: + totalIDPLoginAttempts.Inc() default: totalPasswordLoginAttempts.Inc() } @@ -846,6 +874,8 @@ func incLoginOK(authMethod string) { totalTLSCertLoginOK.Inc() case loginMethodTLSCertificateAndPwd: totalTLSCertAndPwdLoginOK.Inc() + case loginMethodIDP: + totalIDPLoginOK.Inc() default: totalPasswordLoginOK.Inc() } @@ -866,6 +896,8 @@ func incLoginFailed(authMethod string) { totalTLSCertLoginFailed.Inc() case loginMethodTLSCertificateAndPwd: totalTLSCertAndPwdLoginFailed.Inc() + case loginMethodIDP: + totalIDPLoginFailed.Inc() default: totalPasswordLoginFailed.Inc() } diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index cca84714..c5daf90e 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -4356,6 +4356,7 @@ components: - DAV - HTTP - DataRetention + - OIDC description: | Protocols: * `SSH` - SSH commands @@ -4364,6 +4365,7 @@ components: * `DAV` - WebDAV * `HTTP` - WebClient/REST API * `DataRetention` - the event is generated by a data retention check + * `OIDC` - OpenID Connect WebClientOptions: type: string enum: