2022-10-17 10:51:59 +00:00
package authentication
import (
"fmt"
"os"
2022-10-18 00:57:08 +00:00
"strings"
2022-10-17 10:51:59 +00:00
"sync"
"github.com/asaskevich/govalidator"
"github.com/go-crypt/crypt"
2022-10-18 00:57:08 +00:00
"gopkg.in/yaml.v3"
2022-10-17 10:51:59 +00:00
)
// NewFileUserDatabase creates a new FileUserDatabase.
2022-10-18 00:57:08 +00:00
func NewFileUserDatabase ( filePath string , searchEmail , searchCI bool ) ( database * FileUserDatabase ) {
2022-10-17 10:51:59 +00:00
return & FileUserDatabase {
2022-10-18 00:57:08 +00:00
RWMutex : & sync . RWMutex { } ,
Path : filePath ,
Users : map [ string ] DatabaseUserDetails { } ,
Emails : map [ string ] string { } ,
Aliases : map [ string ] string { } ,
SearchEmail : searchEmail ,
SearchCI : searchCI ,
2022-10-17 10:51:59 +00:00
}
}
// FileUserDatabase is a user details database that is concurrency safe database and can be reloaded.
type FileUserDatabase struct {
* sync . RWMutex
2022-10-18 00:57:08 +00:00
Path string
Users map [ string ] DatabaseUserDetails
Emails map [ string ] string
Aliases map [ string ] string
SearchEmail bool
SearchCI bool
2022-10-17 10:51:59 +00:00
}
// 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 := & DatabaseModel { Users : map [ string ] UserDetailsModel { } }
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 ) ; err != nil {
return fmt . Errorf ( "error decoding the authentication database: %w" , err )
}
2022-10-18 00:57:08 +00:00
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
2022-10-17 10:51:59 +00:00
return nil
}
// GetUserDetails get a DatabaseUserDetails given a username as a value type where the username must be the users actual
// username.
func ( m * FileUserDatabase ) GetUserDetails ( username string ) ( user DatabaseUserDetails , err error ) {
m . RLock ( )
defer m . RUnlock ( )
2022-10-18 00:57:08 +00:00
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
}
}
2022-10-17 10:51:59 +00:00
if details , ok := m . Users [ username ] ; ok {
return details , nil
}
return user , ErrUserNotFound
}
// SetUserDetails sets the DatabaseUserDetails for a given user.
func ( m * FileUserDatabase ) SetUserDetails ( username string , details * DatabaseUserDetails ) {
if details == nil {
return
}
m . Lock ( )
m . Users [ username ] = * details
m . Unlock ( )
}
// ToDatabaseModel converts the FileUserDatabase into the DatabaseModel for saving.
func ( m * FileUserDatabase ) ToDatabaseModel ( ) ( model * DatabaseModel ) {
model = & DatabaseModel {
Users : map [ string ] UserDetailsModel { } ,
}
m . RLock ( )
for user , details := range m . Users {
model . Users [ user ] = details . ToUserDetailsModel ( )
}
m . RUnlock ( )
return model
}
// DatabaseUserDetails is the model of user details in the file database.
type DatabaseUserDetails struct {
Username string
Digest crypt . Digest
Disabled bool
DisplayName string
Email string
Groups [ ] string
}
// ToUserDetails converts DatabaseUserDetails into a *UserDetails given a username.
func ( m DatabaseUserDetails ) ToUserDetails ( ) ( details * UserDetails ) {
return & UserDetails {
Username : m . Username ,
DisplayName : m . DisplayName ,
Emails : [ ] string { m . Email } ,
Groups : m . Groups ,
}
}
// ToUserDetailsModel converts DatabaseUserDetails into a UserDetailsModel.
func ( m DatabaseUserDetails ) ToUserDetailsModel ( ) ( model UserDetailsModel ) {
return UserDetailsModel {
HashedPassword : m . Digest . Encode ( ) ,
DisplayName : m . DisplayName ,
Email : m . Email ,
Groups : m . Groups ,
}
}
// DatabaseModel is the model of users file database.
type DatabaseModel struct {
Users map [ string ] UserDetailsModel ` yaml:"users" valid:"required" `
}
// ReadToFileUserDatabase reads the DatabaseModel into a FileUserDatabase.
func ( m * DatabaseModel ) ReadToFileUserDatabase ( db * FileUserDatabase ) ( err error ) {
users := map [ string ] DatabaseUserDetails { }
var udm * DatabaseUserDetails
for user , details := range m . Users {
if udm , err = details . ToDatabaseUserDetailsModel ( user ) ; err != nil {
return fmt . Errorf ( "failed to parse hash for user '%s': %w" , user , err )
}
users [ user ] = * udm
}
db . Users = users
return nil
}
// Read a DatabaseModel from disk.
func ( m * DatabaseModel ) 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 )
}
2022-10-17 11:31:23 +00:00
if len ( content ) == 0 {
return ErrNoContent
}
2022-10-17 10:51:59 +00:00
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 DatabaseModel to disk.
func ( m * DatabaseModel ) Write ( fileName string ) ( err error ) {
var (
data [ ] byte
)
if data , err = yaml . Marshal ( m ) ; err != nil {
return err
}
return os . WriteFile ( fileName , data , fileAuthenticationMode )
}
// UserDetailsModel is the model of user details in the file database.
type UserDetailsModel struct {
HashedPassword string ` yaml:"password" valid:"required" `
DisplayName string ` yaml:"displayname" valid:"required" `
Email string ` yaml:"email" `
Groups [ ] string ` yaml:"groups" `
2022-10-17 22:10:53 +00:00
Disabled bool ` yaml:"disabled" `
2022-10-17 10:51:59 +00:00
}
// ToDatabaseUserDetailsModel converts a UserDetailsModel into a *DatabaseUserDetails.
func ( m UserDetailsModel ) ToDatabaseUserDetailsModel ( username string ) ( model * DatabaseUserDetails , err error ) {
var d crypt . Digest
if d , err = crypt . Decode ( m . HashedPassword ) ; err != nil {
return nil , err
}
return & DatabaseUserDetails {
Username : username ,
Digest : d ,
2022-10-18 00:57:08 +00:00
Disabled : m . Disabled ,
2022-10-17 10:51:59 +00:00
DisplayName : m . DisplayName ,
Email : m . Email ,
Groups : m . Groups ,
} , nil
}