OIDC: add support for implicit roles

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-05-22 14:38:25 +02:00
parent f536c64043
commit 90c21458b8
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
8 changed files with 170 additions and 10 deletions

View file

@ -99,6 +99,7 @@ var (
RedirectBaseURL: "",
UsernameField: "",
RoleField: "",
ImplicitRoles: false,
CustomFields: []string{},
},
Security: httpd.SecurityConf{
@ -1332,6 +1333,12 @@ func getHTTPDOIDCFromEnv(idx int) (httpd.OIDC, bool) {
isSet = true
}
implicitRoles, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__IMPLICIT_ROLES", idx))
if ok {
result.ImplicitRoles = implicitRoles
isSet = true
}
customFields, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__CUSTOM_FIELDS", idx))
if ok {
result.CustomFields = customFields

View file

@ -928,6 +928,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__IMPLICIT_ROLES", "1")
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")
@ -989,6 +990,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__IMPLICIT_ROLES")
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")
@ -1073,6 +1075,7 @@ 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.True(t, bindings[2].OIDC.ImplicitRoles)
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])

View file

@ -261,6 +261,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.
- `implicit_roles`, boolean. If set, the `role_field` is ignored and the SFTPGo role is assumed based on the login link used. Default: `false`.
- `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`.

View file

@ -43,6 +43,7 @@ Add the following configuration parameters to the SFTPGo configuration file (or
"redirect_base_url": "http://192.168.1.50:8080",
"username_field": "preferred_username",
"role_field": "sftpgo_role",
"implicit_roles": false,
"custom_fields": []
}
...
@ -51,6 +52,7 @@ Add the following configuration parameters to the SFTPGo configuration file (or
From SFTPGo login page click `Login with OpenID` button, you will be redirected to the Keycloak login page, after a successful authentication Keyclock will redirect back to SFTPGo Web Admin or SFTPGo Web Client.
Please note that the ID token returned from Keycloak must contain the `username_field` specified in the SFTPGo configuration and optionally the `role_field`. The mapped usernames must exist in SFTPGo.
If you don't want to explicitly define SFTPGo roles in your identity provider, you can set `implicit_roles` to `true`. With this configuration, the SFTPGo role is assumed based on the login link used.
Here is an example ID token which allows the SFTPGo admin `root` to access to the Web Admin UI.

View file

@ -22,6 +22,7 @@ import (
const (
oidcCookieKey = "oidc"
adminRoleFieldValue = "admin"
authStateValidity = 1 * 60 * 1000 // 1 minute
tokenUpdateInterval = 3 * 60 * 1000 // 3 minutes
tokenDeleteInterval = 2 * 3600 * 1000 // 2 hours
@ -66,6 +67,9 @@ type OIDC struct {
// 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"`
// If set, the `RoleField` is ignored and the SFTPGo role is assumed based on
// the login link used
ImplicitRoles bool `json:"implicit_roles" mapstructure:"implicit_roles"`
// Custom token claims fields to pass to the pre-login hook
CustomFields []string `json:"custom_fields" mapstructure:"custom_fields"`
provider *oidc.Provider
@ -79,7 +83,17 @@ func (o *OIDC) isEnabled() bool {
}
func (o *OIDC) hasRoles() bool {
return o.isEnabled() && o.RoleField != ""
return o.isEnabled() && (o.RoleField != "" || o.ImplicitRoles)
}
func (o *OIDC) getForcedRole(audience string) string {
if !o.ImplicitRoles {
return ""
}
if audience == tokenAudienceWebAdmin {
return adminRoleFieldValue
}
return ""
}
func (o *OIDC) getRedirectURL() string {
@ -167,7 +181,9 @@ type oidcToken struct {
UsedAt int64 `json:"used_at"`
}
func (t *oidcToken) parseClaims(claims map[string]any, usernameField, roleField string, customFields []string) error {
func (t *oidcToken) parseClaims(claims map[string]any, usernameField, roleField string, customFields []string,
forcedRole string,
) error {
getClaimsFields := func() []string {
keys := make([]string, 0, len(claims))
for k := range claims {
@ -182,12 +198,16 @@ func (t *oidcToken) parseClaims(claims map[string]any, usernameField, roleField
return errors.New("no username field")
}
t.Username = username
if forcedRole != "" {
t.Role = forcedRole
} else {
if roleField != "" {
role, ok := claims[roleField]
if ok {
t.Role = role
}
}
}
t.CustomFields = nil
if len(customFields) > 0 {
for _, field := range customFields {
@ -213,10 +233,10 @@ func (t *oidcToken) parseClaims(claims map[string]any, usernameField, roleField
func (t *oidcToken) isAdmin() bool {
switch v := t.Role.(type) {
case string:
return v == "admin"
return v == adminRoleFieldValue
case []any:
for _, s := range v {
if val, ok := s.(string); ok && val == "admin" {
if val, ok := s.(string); ok && val == adminRoleFieldValue {
return true
}
}
@ -511,7 +531,9 @@ 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, s.binding.OIDC.CustomFields); err != nil {
err = token.parseClaims(claims, s.binding.OIDC.UsernameField, s.binding.OIDC.RoleField,
s.binding.OIDC.CustomFields, s.binding.OIDC.getForcedRole(authReq.Audience))
if 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()

View file

@ -793,6 +793,129 @@ func TestOIDCToken(t *testing.T) {
assert.NoError(t, err)
}
func TestOIDCImplicitRoles(t *testing.T) {
oidcMgr, ok := oidcMgr.(*memoryOIDCManager)
require.True(t, ok)
server := getTestOIDCServer()
server.binding.OIDC.ImplicitRoles = true
err := server.binding.OIDC.initialize()
assert.NoError(t, err)
server.initializeRouter()
authReq := newOIDCPendingAuth(tokenAudienceWebAdmin)
oidcMgr.addPendingAuth(authReq)
token := &oauth2.Token{
AccessToken: "1234",
Expiry: time.Now().Add(5 * time.Minute),
}
token = token.WithExtra(map[string]any{
"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":"admin","sid":"sid456"}`))
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, webUsersPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
require.Len(t, oidcMgr.tokens, 1)
var tokenCookie string
for k := range oidcMgr.tokens {
tokenCookie = k
}
// Web Client is not available with an admin token
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
// logout the admin user
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webLogoutPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
require.Len(t, oidcMgr.tokens, 0)
// now login and logout a user
username := "test_oidc_implicit_user"
user := dataprovider.User{
BaseUser: sdk.BaseUser{
Username: username,
Password: "pwd",
HomeDir: filepath.Join(os.TempDir(), username),
Status: 1,
Permissions: map[string][]string{
"/": {dataprovider.PermAny},
},
},
Filters: dataprovider.UserFilters{
BaseUserFilters: sdk.BaseUserFilters{
WebClient: []string{sdk.WebClientSharesDisabled},
},
},
}
err = dataprovider.AddUser(&user, "", "")
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":"test_oidc_implicit_user"}`))
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"))
require.Len(t, oidcMgr.pendingAuths, 0)
require.Len(t, oidcMgr.tokens, 1)
for k := range oidcMgr.tokens {
tokenCookie = k
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webClientLogoutPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
require.Len(t, oidcMgr.tokens, 0)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
err = dataprovider.DeleteUser(username, "", "")
assert.NoError(t, err)
}
func TestMemoryOIDCManager(t *testing.T) {
oidcMgr, ok := oidcMgr.(*memoryOIDCManager)
require.True(t, ok)
@ -1139,6 +1262,7 @@ func getTestOIDCServer() *httpdServer {
RedirectBaseURL: "http://127.0.0.1:8081/",
UsernameField: "preferred_username",
RoleField: "sftpgo_role",
ImplicitRoles: false,
CustomFields: nil,
},
},

View file

@ -245,6 +245,7 @@
"redirect_base_url": "",
"username_field": "",
"role_field": "",
"implicit_roles": false,
"custom_fields": []
},
"security": {

View file

@ -30,7 +30,7 @@
<tr>
<th>Name</th>
<th>Description</th>
<th>Used by</th>
<th>Members</th>
</tr>
</thead>
<tbody>