package authentication import ( "fmt" "net/url" "os" "strings" "sync" "github.com/asaskevich/govalidator" "github.com/go-crypt/crypt" "github.com/go-crypt/crypt/algorithm" "golang.org/x/text/language" "gopkg.in/yaml.v3" "github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/expression" ) type FileUserProviderDatabase interface { Save() (err error) Load() (err error) GetUserDetails(username string) (user FileUserDatabaseUserDetails, err error) SetUserDetails(username string, details *FileUserDatabaseUserDetails) } // NewFileUserDatabase creates a new FileUserDatabase. func NewFileUserDatabase(filePath string, searchEmail, searchCI bool, extra map[string]expression.ExtraAttribute) (database *FileUserDatabase) { return &FileUserDatabase{ RWMutex: &sync.RWMutex{}, Path: filePath, Users: map[string]FileUserDatabaseUserDetails{}, Emails: map[string]string{}, Aliases: map[string]string{}, SearchEmail: searchEmail, SearchCI: searchCI, Extra: extra, } } // FileUserDatabase is a user details database that is concurrency safe database and can be reloaded. type FileUserDatabase struct { *sync.RWMutex `json:"-"` Users map[string]FileUserDatabaseUserDetails `json:"users" jsonschema:"required,title=Users" jsonschema_description:"The dictionary of users."` Path string `json:"-"` Emails map[string]string `json:"-"` Aliases map[string]string `json:"-"` SearchEmail bool `json:"-"` SearchCI bool `json:"-"` Extra map[string]expression.ExtraAttribute } // Save the database to disk. func (m *FileUserDatabase) Save() (err error) { m.RLock() defer m.RUnlock() if err = m.ToDatabaseModel().Write(m.Path); err != nil { return err } return nil } // Load the database from disk. func (m *FileUserDatabase) Load() (err error) { yml := &FileDatabaseModel{Users: map[string]FileDatabaseUserDetailsModel{}} if err = yml.Read(m.Path); err != nil { return fmt.Errorf("error reading the authentication database: %w", err) } m.Lock() defer m.Unlock() if err = yml.ReadToFileUserDatabase(m, m.Extra); err != nil { 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 FileUserDatabaseUserDetails) (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 } // GetUserDetails get a FileUserDatabaseUserDetails given a username as a value type where the username must be the users actual // username. func (m *FileUserDatabase) GetUserDetails(username string) (user FileUserDatabaseUserDetails, err error) { m.RLock() 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 } return user, ErrUserNotFound } // SetUserDetails sets the FileUserDatabaseUserDetails for a given user. func (m *FileUserDatabase) SetUserDetails(username string, details *FileUserDatabaseUserDetails) { if details == nil { return } m.Lock() m.Users[username] = *details m.Unlock() } // ToDatabaseModel converts the FileUserDatabase into the FileDatabaseModel for saving. func (m *FileUserDatabase) ToDatabaseModel() (model *FileDatabaseModel) { model = &FileDatabaseModel{ Users: map[string]FileDatabaseUserDetailsModel{}, } m.RLock() for user, details := range m.Users { model.Users[user] = details.ToUserDetailsModel() } m.RUnlock() return model } // FileUserDatabaseUserDetails is the model of user details in the file database. type FileUserDatabaseUserDetails struct { Username string `json:"-"` Password *schema.PasswordDigest `json:"password" jsonschema:"required,title=Password" jsonschema_description:"The hashed password for the user."` DisplayName string `json:"displayname" jsonschema:"required,title=Display Name" jsonschema_description:"The display name for the user."` GivenName string `json:"given_name,omitempty" jsonschema:"title=Given Name" jsonschema_description:"The given name for the user."` MiddleName string `json:"middle_name,omitempty" jsonschema:"title=Middle Name" jsonschema_description:"The middle name for the user."` FamilyName string `json:"family_name,omitempty" jsonschema:"title=Family Name" jsonschema_description:"The family name for the user."` Nickname string `json:"nickname,omitempty" jsonschema:"title=Nickname" jsonschema_description:"The nickname for the user."` Gender string `json:"gender,omitempty" jsonschema:"title=Gender" jsonschema_description:"The gender for the user."` Birthdate string `json:"birthdate,omitempty" jsonschema:"title=Birthdate" jsonschema_description:"The birthdate for the user."` Website *url.URL `json:"website,omitempty" jsonschema:"title=Website" jsonschema_description:"The website URL for the user."` Profile *url.URL `json:"profile,omitempty" jsonschema:"title=Profile" jsonschema_description:"The profile URL for the user."` Picture *url.URL `json:"picture,omitempty" jsonschema:"title=Picture" jsonschema_description:"The picture URL for the user."` ZoneInfo string `json:"zoneinfo,omitempty" jsonschema:"title=Zone Information" jsonschema_description:"The time zone for the user."` Locale *language.Tag `json:"locale,omitempty" jsonschema:"title=Locale" jsonschema_description:"The BCP47 locale for the user."` PhoneNumber string `json:"phone_number,omitempty" jsonschema:"title=Phone Number" jsonschema_description:"The phone number for the user."` PhoneExtension string `json:"phone_extension,omitempty" jsonschema:"title=Phone Extension" jsonschema_description:"The phone extension for the user."` Email string `json:"email" jsonschema:"title=Email" jsonschema_description:"The email for the user."` Groups []string `json:"groups" jsonschema:"title=Groups" jsonschema_description:"The groups list for the user."` Disabled bool `json:"disabled" jsonschema:"default=false,title=Disabled" jsonschema_description:"The disabled status for the user."` Address *FileUserDatabaseUserDetailsAddressModel `json:"address,omitempty" jsonschema:"title=Address" jsonschema_description:"The address for the user."` Extra map[string]any `json:"extra" jsonschema:"title=Extra" jsonschema_description:"The extra attributes for the user."` } type FileUserDatabaseUserDetailsAddressModel struct { StreetAddress string `yaml:"street_address" json:"street_address,omitempty" jsonschema:"title=Street Address" jsonschema_description:"The street address for the user."` Locality string `yaml:"locality" json:"locality,omitempty" jsonschema:"title=Locality" jsonschema_description:"The locality for the user."` Region string `yaml:"region" json:"region,omitempty" jsonschema:"title=Region" jsonschema_description:"The region for the user."` PostalCode string `yaml:"postal_code" json:"postal_code,omitempty" jsonschema:"title=Postal Code" jsonschema_description:"The postal code or postcode for the user."` Country string `yaml:"country" json:"country,omitempty" jsonschema:"title=Country" jsonschema_description:"The country for the user."` } // ToUserDetails converts FileUserDatabaseUserDetails into a *UserDetails. func (m FileUserDatabaseUserDetails) ToUserDetails() (details *UserDetails) { var emails []string if m.Email != "" { emails = append(emails, m.Email) } return &UserDetails{ Username: m.Username, DisplayName: m.DisplayName, Emails: emails, Groups: m.Groups, } } // ToExtendedUserDetails converts FileUserDatabaseUserDetails into a *UserDetailsExtended. func (m FileUserDatabaseUserDetails) ToExtendedUserDetails() (details *UserDetailsExtended) { details = &UserDetailsExtended{ GivenName: m.GivenName, FamilyName: m.FamilyName, MiddleName: m.MiddleName, Nickname: m.Nickname, Profile: m.Profile, Picture: m.Picture, Website: m.Website, Gender: m.Gender, Birthdate: m.Birthdate, ZoneInfo: m.ZoneInfo, Locale: m.Locale, PhoneNumber: m.PhoneNumber, PhoneExtension: m.PhoneExtension, UserDetails: m.ToUserDetails(), Extra: m.Extra, } if m.Address != nil { details.Address = &UserDetailsAddress{ StreetAddress: m.Address.StreetAddress, Locality: m.Address.Locality, Region: m.Address.Region, PostalCode: m.Address.PostalCode, Country: m.Address.Country, } } return details } // ToUserDetailsModel converts FileUserDatabaseUserDetails into a FileDatabaseUserDetailsModel. func (m FileUserDatabaseUserDetails) ToUserDetailsModel() (model FileDatabaseUserDetailsModel) { model = FileDatabaseUserDetailsModel{ Password: m.Password.Encode(), DisplayName: m.DisplayName, GivenName: m.GivenName, MiddleName: m.MiddleName, FamilyName: m.FamilyName, Nickname: m.Nickname, Gender: m.Gender, Birthdate: m.Birthdate, ZoneInfo: m.ZoneInfo, PhoneNumber: m.PhoneNumber, PhoneExtension: m.PhoneExtension, Email: m.Email, Groups: m.Groups, Address: m.Address, Extra: m.Extra, } if m.Website != nil { model.Website = m.Website.String() } if m.Profile != nil { model.Profile = m.Profile.String() } if m.Picture != nil { model.Picture = m.Picture.String() } if m.Locale != nil { model.Locale = m.Locale.String() } return model } // FileDatabaseModel is the model of users file database. type FileDatabaseModel struct { Users map[string]FileDatabaseUserDetailsModel `yaml:"users" json:"users" valid:"required" jsonschema:"required,title=Users" jsonschema_description:"The dictionary of users."` } // ReadToFileUserDatabase reads the FileDatabaseModel into a FileUserDatabase. func (m *FileDatabaseModel) ReadToFileUserDatabase(db *FileUserDatabase, extra map[string]expression.ExtraAttribute) (err error) { users := map[string]FileUserDatabaseUserDetails{} var udm *FileUserDatabaseUserDetails for username, details := range m.Users { if err = details.ValidateExtra(username, extra); err != nil { return err } if udm, err = details.ToDatabaseUserDetailsModel(username); err != nil { return err } users[username] = *udm } db.Users = users return nil } // Read a FileDatabaseModel from disk. func (m *FileDatabaseModel) Read(filePath string) (err error) { var ( content []byte ok bool ) if content, err = os.ReadFile(filePath); err != nil { return fmt.Errorf("failed to read the '%s' file: %w", filePath, err) } if len(content) == 0 { return ErrNoContent } if err = yaml.Unmarshal(content, m); err != nil { return fmt.Errorf("could not parse the YAML database: %w", err) } if ok, err = govalidator.ValidateStruct(m); err != nil { return fmt.Errorf("could not validate the schema: %w", err) } if !ok { return fmt.Errorf("the schema is invalid") } return nil } // Write a FileDatabaseModel to disk. func (m *FileDatabaseModel) Write(fileName string) (err error) { var ( data []byte ) if data, err = yaml.Marshal(m); err != nil { return err } return os.WriteFile(fileName, data, fileAuthenticationMode) } // FileDatabaseUserDetailsModel is the model of user details in the file database. type FileDatabaseUserDetailsModel struct { Password string `yaml:"password" valid:"required"` DisplayName string `yaml:"displayname" valid:"required"` Email string `yaml:"email"` Groups []string `yaml:"groups"` GivenName string `yaml:"given_name"` MiddleName string `yaml:"middle_name"` FamilyName string `yaml:"family_name"` Nickname string `yaml:"nickname"` Gender string `yaml:"gender"` Birthdate string `yaml:"birthdate"` Website string `yaml:"website"` Profile string `yaml:"profile"` Picture string `yaml:"picture"` ZoneInfo string `yaml:"zoneinfo"` Locale string `yaml:"locale"` PhoneNumber string `yaml:"phone_number"` PhoneExtension string `yaml:"phone_extension"` Disabled bool `yaml:"disabled"` Address *FileUserDatabaseUserDetailsAddressModel `yaml:"address"` Extra map[string]any `yaml:"extra"` } //nolint:gocyclo func (m FileDatabaseUserDetailsModel) ValidateExtra(username string, extra map[string]expression.ExtraAttribute) (err error) { for name, value := range m.Extra { attribute, ok := extra[name] if !ok { return fmt.Errorf("error occurred validating extra attributes for user '%s': attribute '%s' is unknown", username, name) } mv := attribute.IsMultiValued() vt := attribute.GetValueType() if !mv { switch value.(type) { case string: if vt == ValueTypeString { continue } case int, int64, int32, float64, float32: if vt == ValueTypeInteger { continue } case bool: if vt == ValueTypeBoolean { continue } default: return fmt.Errorf("error occurred validating extra attributes for user '%s': attribute '%s' has the unknown type '%T'", username, name, value) } return fmt.Errorf("error occurred validating extra attributes for user '%s': attribute '%s' has the known type '%T' but '%s' is the expected type", username, name, value, vt) } values, ok := value.([]any) if !ok { return fmt.Errorf("error occurred validating extra attributes for user '%s': attribute '%s' has the type '%T' but '[]%s' is the expected type", username, name, value, vt) } for _, v := range values { switch v.(type) { case string: if vt == ValueTypeString { continue } case int, int64, int32, float64, float32: if vt == ValueTypeInteger { continue } case bool: if vt == ValueTypeBoolean { continue } default: return fmt.Errorf("error occurred validating extra attributes for user '%s': attribute '%s' has the unknown item type '%T'", username, name, v) } } } return nil } // ToDatabaseUserDetailsModel converts a FileDatabaseUserDetailsModel into a *FileUserDatabaseUserDetails. func (m FileDatabaseUserDetailsModel) ToDatabaseUserDetailsModel(username string) (model *FileUserDatabaseUserDetails, err error) { var d algorithm.Digest if d, err = crypt.Decode(m.Password); err != nil { return nil, fmt.Errorf("error occurred decoding the password hash for '%s': %w", username, err) } model = &FileUserDatabaseUserDetails{ Username: username, Password: schema.NewPasswordDigest(d), Disabled: m.Disabled, DisplayName: m.DisplayName, Email: m.Email, GivenName: m.GivenName, MiddleName: m.MiddleName, FamilyName: m.FamilyName, Nickname: m.Nickname, Gender: m.Gender, Birthdate: m.Birthdate, ZoneInfo: m.ZoneInfo, PhoneNumber: m.PhoneNumber, PhoneExtension: m.PhoneExtension, Groups: m.Groups, Address: m.Address, Extra: m.Extra, } if m.Website != "" { if model.Website, err = parseAttributeURI(username, "", "website", m.Website); err != nil { return nil, err } } if m.Profile != "" { if model.Profile, err = parseAttributeURI(username, "", "profile", m.Profile); err != nil { return nil, err } } if m.Picture != "" { if model.Picture, err = parseAttributeURI(username, "", "picture", m.Picture); err != nil { return nil, err } } if m.Locale != "" { var tag language.Tag if tag, err = language.Parse(m.Locale); err != nil { return nil, fmt.Errorf("error occurred parsing user details for '%s': failed to parse the locale attribute with value '%s': %w", username, m.Locale, err) } model.Locale = &tag } return model, nil }