feat(authentication): file case-insensitive and email search (#4194)

This allows both case-insensitive and email searching for the file auth provider.

Closes #3383
pull/4192/head^2
James Elliott 2022-10-18 11:57:08 +11:00 committed by GitHub
parent d610874be4
commit a0b2e78e5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 377 additions and 14 deletions

View File

@ -392,6 +392,10 @@ authentication_backend:
## ##
# file: # file:
# path: /config/users_database.yml # path: /config/users_database.yml
# watch: false
# search:
# email: false
# case_insensitive: false
# password: # password:
# algorithm: argon2 # algorithm: argon2
# argon2: # argon2:

View File

@ -21,6 +21,9 @@ authentication_backend:
file: file:
path: /config/users.yml path: /config/users.yml
watch: false watch: false
search:
email: false
case_insensitive: false
password: password:
algorithm: argon2 algorithm: argon2
argon2: argon2:
@ -65,6 +68,30 @@ The path to the file with the user details list. Supported file types are:
Enables reloading the database by watching it for changes. Enables reloading the database by watching it for changes.
### search
Username searching functionality options.
*__Important Note:__ This functionality is experimental.*
#### email
{{< confkey type="boolean" default="false" required="no" >}}
Allows users to login using their email address. If enabled two users must not have the same emails and their usernames
must not be an email.
*__Note:__ Emails are always checked using case-insensitive lookup.*
#### case_insensitive
{{< confkey type="boolean" default="false" required="no" >}}
Enabling this search option allows users to login with their username regardless of case. If enabled users must only
have lowercase usernames.
*__Note:__ Emails are always checked using case-insensitive lookup.*
## Password Options ## Password Options
A [reference guide](../../reference/guides/passwords.md) exists specifically for choosing password hashing values. This A [reference guide](../../reference/guides/passwords.md) exists specifically for choosing password hashing values. This

File diff suppressed because one or more lines are too long

View File

@ -131,7 +131,7 @@ func (p *FileUserProvider) StartupCheck() (err error) {
return err return err
} }
p.database = NewFileUserDatabase(p.config.Path) p.database = NewFileUserDatabase(p.config.Path, p.config.Search.Email, p.config.Search.CaseInsensitive)
if err = p.database.Load(); err != nil { if err = p.database.Load(); err != nil {
return err return err

View File

@ -3,19 +3,24 @@ package authentication
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"sync" "sync"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"github.com/go-crypt/crypt" "github.com/go-crypt/crypt"
yaml "gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// NewFileUserDatabase creates a new FileUserDatabase. // NewFileUserDatabase creates a new FileUserDatabase.
func NewFileUserDatabase(filePath string) (database *FileUserDatabase) { func NewFileUserDatabase(filePath string, searchEmail, searchCI bool) (database *FileUserDatabase) {
return &FileUserDatabase{ return &FileUserDatabase{
RWMutex: &sync.RWMutex{}, RWMutex: &sync.RWMutex{},
Path: filePath, Path: filePath,
Users: map[string]DatabaseUserDetails{}, Users: map[string]DatabaseUserDetails{},
Emails: map[string]string{},
Aliases: map[string]string{},
SearchEmail: searchEmail,
SearchCI: searchCI,
} }
} }
@ -25,6 +30,11 @@ type FileUserDatabase struct {
Path string Path string
Users map[string]DatabaseUserDetails Users map[string]DatabaseUserDetails
Emails map[string]string
Aliases map[string]string
SearchEmail bool
SearchCI bool
} }
// Save the database to disk. // Save the database to disk.
@ -56,6 +66,79 @@ func (m *FileUserDatabase) Load() (err error) {
return fmt.Errorf("error decoding the authentication database: %w", err) return fmt.Errorf("error decoding the authentication database: %w", err)
} }
return m.LoadAliases()
}
// LoadAliases performs the loading of alias information from the database.
func (m *FileUserDatabase) LoadAliases() (err error) {
if m.SearchEmail || m.SearchCI {
for k, user := range m.Users {
if m.SearchEmail && user.Email != "" {
if err = m.loadAliasEmail(k, user); err != nil {
return err
}
}
if m.SearchCI {
if err = m.loadAlias(k); err != nil {
return err
}
}
}
}
return nil
}
func (m *FileUserDatabase) loadAlias(k string) (err error) {
u := strings.ToLower(k)
if u != k {
return fmt.Errorf("error loading authentication database: username '%s' is not lowercase but this is required when case-insensitive search is enabled", k)
}
for username, details := range m.Users {
if k == username {
continue
}
if strings.EqualFold(u, details.Email) {
return fmt.Errorf("error loading authentication database: username '%s' is configured as an email for user with username '%s' which isn't allowed when case-insensitive search is enabled", u, username)
}
}
m.Aliases[u] = k
return nil
}
func (m *FileUserDatabase) loadAliasEmail(k string, user DatabaseUserDetails) (err error) {
e := strings.ToLower(user.Email)
var duplicates []string
for username, details := range m.Users {
if k == username {
continue
}
if strings.EqualFold(e, details.Email) {
duplicates = append(duplicates, username)
}
}
if len(duplicates) != 0 {
duplicates = append(duplicates, k)
return fmt.Errorf("error loading authentication database: email '%s' is configured for for more than one user (users are '%s') which isn't allowed when email search is enabled", e, strings.Join(duplicates, "', '"))
}
if _, ok := m.Users[e]; ok && k != e {
return fmt.Errorf("error loading authentication database: email '%s' is also a username which isn't allowed when email search is enabled", e)
}
m.Emails[e] = k
return nil return nil
} }
@ -66,6 +149,20 @@ func (m *FileUserDatabase) GetUserDetails(username string) (user DatabaseUserDet
defer m.RUnlock() defer m.RUnlock()
u := strings.ToLower(username)
if m.SearchEmail {
if key, ok := m.Emails[u]; ok {
return m.Users[key], nil
}
}
if m.SearchCI {
if key, ok := m.Aliases[u]; ok {
return m.Users[key], nil
}
}
if details, ok := m.Users[username]; ok { if details, ok := m.Users[username]; ok {
return details, nil return details, nil
} }
@ -145,10 +242,6 @@ func (m *DatabaseModel) ReadToFileUserDatabase(db *FileUserDatabase) (err error)
var udm *DatabaseUserDetails var udm *DatabaseUserDetails
for user, details := range m.Users { for user, details := range m.Users {
if details.Disabled {
continue
}
if udm, err = details.ToDatabaseUserDetailsModel(user); err != nil { if udm, err = details.ToDatabaseUserDetailsModel(user); err != nil {
return fmt.Errorf("failed to parse hash for user '%s': %w", user, err) return fmt.Errorf("failed to parse hash for user '%s': %w", user, err)
} }
@ -224,6 +317,7 @@ func (m UserDetailsModel) ToDatabaseUserDetailsModel(username string) (model *Da
return &DatabaseUserDetails{ return &DatabaseUserDetails{
Username: username, Username: username,
Digest: d, Digest: d,
Disabled: m.Disabled,
DisplayName: m.DisplayName, DisplayName: m.DisplayName,
Email: m.Email, Email: m.Email,
Groups: m.Groups, Groups: m.Groups,

View File

@ -3,6 +3,7 @@ package authentication
import ( import (
"log" "log"
"os" "os"
"regexp"
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
@ -304,6 +305,137 @@ func TestShouldSupportHashPasswordWithoutCRYPT(t *testing.T) {
}) })
} }
func TestShouldNotAllowLoginOfDisabledUsers(t *testing.T) {
WithDatabase(UserDatabaseContent, func(path string) {
config := DefaultFileAuthenticationBackendConfiguration
config.Path = path
provider := NewFileUserProvider(&config)
assert.NoError(t, provider.StartupCheck())
ok, err := provider.CheckUserPassword("dis", "password")
assert.False(t, ok)
assert.EqualError(t, err, "user not found")
})
}
func TestShouldErrorOnInvalidCaseSensitiveFile(t *testing.T) {
WithDatabase(UserDatabaseContentInvalidSearchCaseInsenstive, func(path string) {
config := DefaultFileAuthenticationBackendConfiguration
config.Path = path
config.Search.Email = false
config.Search.CaseInsensitive = true
provider := NewFileUserProvider(&config)
assert.EqualError(t, provider.StartupCheck(), "error loading authentication database: username 'JOHN' is not lowercase but this is required when case-insensitive search is enabled")
})
}
func TestShouldErrorOnDuplicateEmail(t *testing.T) {
WithDatabase(UserDatabaseContentInvalidSearchEmail, func(path string) {
config := DefaultFileAuthenticationBackendConfiguration
config.Path = path
config.Search.Email = true
config.Search.CaseInsensitive = false
provider := NewFileUserProvider(&config)
err := provider.StartupCheck()
assert.Regexp(t, regexp.MustCompile(`^error loading authentication database: email 'john.doe@authelia.com' is configured for for more than one user \(users are '(harry|john)', '(harry|john)'\) which isn't allowed when email search is enabled$`), err.Error())
})
}
func TestShouldNotErrorOnEmailAsUsername(t *testing.T) {
WithDatabase(UserDatabaseContentSearchEmailAsUsername, func(path string) {
config := DefaultFileAuthenticationBackendConfiguration
config.Path = path
config.Search.Email = true
config.Search.CaseInsensitive = false
provider := NewFileUserProvider(&config)
assert.NoError(t, provider.StartupCheck())
})
}
func TestShouldErrorOnEmailAsUsernameWithDuplicateEmail(t *testing.T) {
WithDatabase(UserDatabaseContentInvalidSearchEmailAsUsername, func(path string) {
config := DefaultFileAuthenticationBackendConfiguration
config.Path = path
config.Search.Email = true
config.Search.CaseInsensitive = false
provider := NewFileUserProvider(&config)
assert.EqualError(t, provider.StartupCheck(), "error loading authentication database: email 'john.doe@authelia.com' is also a username which isn't allowed when email search is enabled")
})
}
func TestShouldErrorOnEmailAsUsernameWithDuplicateEmailCase(t *testing.T) {
WithDatabase(UserDatabaseContentInvalidSearchEmailAsUsernameCase, func(path string) {
config := DefaultFileAuthenticationBackendConfiguration
config.Path = path
config.Search.Email = false
config.Search.CaseInsensitive = true
provider := NewFileUserProvider(&config)
assert.EqualError(t, provider.StartupCheck(), "error loading authentication database: username 'john.doe@authelia.com' is configured as an email for user with username 'john' which isn't allowed when case-insensitive search is enabled")
})
}
func TestShouldAllowLookupByEmail(t *testing.T) {
WithDatabase(UserDatabaseContent, func(path string) {
config := DefaultFileAuthenticationBackendConfiguration
config.Path = path
config.Search.Email = true
provider := NewFileUserProvider(&config)
assert.NoError(t, provider.StartupCheck())
ok, err := provider.CheckUserPassword("john", "password")
assert.NoError(t, err)
assert.True(t, ok)
ok, err = provider.CheckUserPassword("john.doe@authelia.com", "password")
assert.NoError(t, err)
assert.True(t, ok)
ok, err = provider.CheckUserPassword("JOHN.doe@authelia.com", "password")
assert.NoError(t, err)
assert.True(t, ok)
})
}
func TestShouldAllowLookupCI(t *testing.T) {
WithDatabase(UserDatabaseContent, func(path string) {
config := DefaultFileAuthenticationBackendConfiguration
config.Path = path
config.Search.CaseInsensitive = true
provider := NewFileUserProvider(&config)
assert.NoError(t, provider.StartupCheck())
ok, err := provider.CheckUserPassword("john", "password")
assert.NoError(t, err)
assert.True(t, ok)
ok, err = provider.CheckUserPassword("John", "password")
assert.NoError(t, err)
assert.True(t, ok)
})
}
var ( var (
DefaultFileAuthenticationBackendConfiguration = schema.FileAuthenticationBackend{ DefaultFileAuthenticationBackendConfiguration = schema.FileAuthenticationBackend{
Path: "", Path: "",
@ -343,7 +475,99 @@ users:
enumeration: enumeration:
displayname: "Enumeration" displayname: "Enumeration"
password: "$argon2id$v=19$m=131072,p=8$BpLnfgDsc2WD8F2q$O126GHPeZ5fwj7OLSs7PndXsTbje76R+QW9/EGfhkJg" password: "$argon2id$v=19$m=131072,p=8$BpLnfgDsc2WD8F2q$O126GHPeZ5fwj7OLSs7PndXsTbje76R+QW9/EGfhkJg"
email: james.dean@authelia.com email: enumeration@authelia.com
dis:
displayname: "Enumeration"
password: "$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM"
disabled: true
email: disabled@authelia.com
`)
var UserDatabaseContentInvalidSearchCaseInsenstive = []byte(`
users:
john:
displayname: "John Doe"
password: "{CRYPT}$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM"
email: john.doe@authelia.com
groups:
- admins
- dev
JOHN:
displayname: "Harry Potter"
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: harry.potter@authelia.com
groups: []
`)
var UserDatabaseContentInvalidSearchEmail = []byte(`
users:
john:
displayname: "John Doe"
password: "{CRYPT}$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM"
email: john.doe@authelia.com
groups:
- admins
- dev
harry:
displayname: "Harry Potter"
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: john.doe@authelia.com
groups: []
`)
var UserDatabaseContentSearchEmailAsUsername = []byte(`
users:
john.doe@authelia.com:
displayname: "John Doe"
password: "{CRYPT}$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM"
email: john.doe@authelia.com
groups:
- admins
- dev
harry:
displayname: "Harry Potter"
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: harry.potter@authelia.com
groups: []
`)
var UserDatabaseContentInvalidSearchEmailAsUsername = []byte(`
users:
john.doe@authelia.com:
displayname: "John Doe"
password: "{CRYPT}$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM"
email: john@authelia.com
groups:
- admins
- dev
harry:
displayname: "Harry Potter"
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: john.doe@authelia.com
groups: []
`)
var UserDatabaseContentInvalidSearchEmailAsUsernameCase = []byte(`
users:
john.doe@authelia.com:
displayname: "John Doe"
password: "{CRYPT}$argon2id$v=19$m=65536,t=3,p=2$BpLnfgDsc2WD8F2q$o/vzA4myCqZZ36bUGsDY//8mKUYNZZaR0t4MFFSs+iM"
email: JOHN@authelia.com
groups:
- admins
- dev
john:
displayname: "John Potter"
password: "{CRYPT}$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: john.doe@authelia.com
groups: []
`) `)
var MalformedUserDatabaseContent = []byte(` var MalformedUserDatabaseContent = []byte(`

View File

@ -392,6 +392,10 @@ authentication_backend:
## ##
# file: # file:
# path: /config/users_database.yml # path: /config/users_database.yml
# watch: false
# search:
# email: false
# case_insensitive: false
# password: # password:
# algorithm: argon2 # algorithm: argon2
# argon2: # argon2:

View File

@ -26,6 +26,14 @@ type FileAuthenticationBackend struct {
Path string `koanf:"path"` Path string `koanf:"path"`
Watch bool `koanf:"watch"` Watch bool `koanf:"watch"`
Password Password `koanf:"password"` Password Password `koanf:"password"`
Search FileSearchAuthenticationBackend `koanf:"search"`
}
// FileSearchAuthenticationBackend represents the configuration related to file-based backend searching.
type FileSearchAuthenticationBackend struct {
Email bool `koanf:"email"`
CaseInsensitive bool `koanf:"case_insensitive"`
} }
// Password represents the configuration related to password hashing. // Password represents the configuration related to password hashing.

View File

@ -76,6 +76,8 @@ var Keys = []string{
"authentication_backend.file.password.parallelism", "authentication_backend.file.password.parallelism",
"authentication_backend.file.password.key_length", "authentication_backend.file.password.key_length",
"authentication_backend.file.password.salt_length", "authentication_backend.file.password.salt_length",
"authentication_backend.file.search.email",
"authentication_backend.file.search.case_insensitive",
"authentication_backend.ldap.implementation", "authentication_backend.ldap.implementation",
"authentication_backend.ldap.url", "authentication_backend.ldap.url",
"authentication_backend.ldap.timeout", "authentication_backend.ldap.timeout",