feat(authentication): file case-insensitive and email search (#4194)
This allows both case-insensitive and email searching for the file auth provider. Closes #3383pull/4192/head^2
parent
d610874be4
commit
a0b2e78e5d
|
@ -392,6 +392,10 @@ authentication_backend:
|
|||
##
|
||||
# file:
|
||||
# path: /config/users_database.yml
|
||||
# watch: false
|
||||
# search:
|
||||
# email: false
|
||||
# case_insensitive: false
|
||||
# password:
|
||||
# algorithm: argon2
|
||||
# argon2:
|
||||
|
|
|
@ -21,6 +21,9 @@ authentication_backend:
|
|||
file:
|
||||
path: /config/users.yml
|
||||
watch: false
|
||||
search:
|
||||
email: false
|
||||
case_insensitive: false
|
||||
password:
|
||||
algorithm: 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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
@ -131,7 +131,7 @@ func (p *FileUserProvider) StartupCheck() (err error) {
|
|||
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 {
|
||||
return err
|
||||
|
|
|
@ -3,19 +3,24 @@ package authentication
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/go-crypt/crypt"
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// NewFileUserDatabase creates a new FileUserDatabase.
|
||||
func NewFileUserDatabase(filePath string) (database *FileUserDatabase) {
|
||||
func NewFileUserDatabase(filePath string, searchEmail, searchCI bool) (database *FileUserDatabase) {
|
||||
return &FileUserDatabase{
|
||||
RWMutex: &sync.RWMutex{},
|
||||
Path: filePath,
|
||||
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
|
||||
Users map[string]DatabaseUserDetails
|
||||
Emails map[string]string
|
||||
Aliases map[string]string
|
||||
|
||||
SearchEmail bool
|
||||
SearchCI bool
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
|
||||
|
@ -66,6 +149,20 @@ func (m *FileUserDatabase) GetUserDetails(username string) (user DatabaseUserDet
|
|||
|
||||
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 {
|
||||
return details, nil
|
||||
}
|
||||
|
@ -145,10 +242,6 @@ func (m *DatabaseModel) ReadToFileUserDatabase(db *FileUserDatabase) (err error)
|
|||
var udm *DatabaseUserDetails
|
||||
|
||||
for user, details := range m.Users {
|
||||
if details.Disabled {
|
||||
continue
|
||||
}
|
||||
|
||||
if udm, err = details.ToDatabaseUserDetailsModel(user); err != nil {
|
||||
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{
|
||||
Username: username,
|
||||
Digest: d,
|
||||
Disabled: m.Disabled,
|
||||
DisplayName: m.DisplayName,
|
||||
Email: m.Email,
|
||||
Groups: m.Groups,
|
||||
|
|
|
@ -3,6 +3,7 @@ package authentication
|
|||
import (
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"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 (
|
||||
DefaultFileAuthenticationBackendConfiguration = schema.FileAuthenticationBackend{
|
||||
Path: "",
|
||||
|
@ -343,7 +475,99 @@ users:
|
|||
enumeration:
|
||||
displayname: "Enumeration"
|
||||
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(`
|
||||
|
|
|
@ -392,6 +392,10 @@ authentication_backend:
|
|||
##
|
||||
# file:
|
||||
# path: /config/users_database.yml
|
||||
# watch: false
|
||||
# search:
|
||||
# email: false
|
||||
# case_insensitive: false
|
||||
# password:
|
||||
# algorithm: argon2
|
||||
# argon2:
|
||||
|
|
|
@ -26,6 +26,14 @@ type FileAuthenticationBackend struct {
|
|||
Path string `koanf:"path"`
|
||||
Watch bool `koanf:"watch"`
|
||||
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.
|
||||
|
|
|
@ -76,6 +76,8 @@ var Keys = []string{
|
|||
"authentication_backend.file.password.parallelism",
|
||||
"authentication_backend.file.password.key_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.url",
|
||||
"authentication_backend.ldap.timeout",
|
||||
|
|
Loading…
Reference in New Issue