From 660c44992e038d6895cd96e4aba100e5dd2bb013 Mon Sep 17 00:00:00 2001 From: Arthur Lefebvre Date: Sun, 1 Mar 2026 00:15:09 +0100 Subject: [PATCH] Add a config to create new users on startup --- README.md | 22 +++++++++ internal/config/config.go | 32 ++++++++---- internal/createuser/createuser.go | 82 +++++++++++++++++++++++++++++++ internal/routes/usersignup.go | 39 +-------------- internal/setup/setup.go | 5 ++ 5 files changed, 134 insertions(+), 46 deletions(-) create mode 100644 README.md create mode 100644 internal/createuser/createuser.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..539f28c --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +## Generate new accounts on startup + +`--add-user` or `-a` can be used to create an account on startup. It requires a string following htpasswd format `[username]:[bcrypt hashed password]`. + +The password can be generated using `htpasswd -nB [username]`. + +For example, to create an user account `demo`: + +```bash +htpasswd -nBC10 demo +New password: +Re-type new password: +demo:$2y$10$UHR2646SZo2W.Rhna7bn5eWNLXWJZ/Sa3oLd9RlxlXs57Bwp6isOS +``` + +Then, starting the server: + +``` +./PersonalLibraryManager -a 'demo:$2y$10$UHR2646SZo2W.Rhna7bn5eWNLXWJZ/Sa3oLd9RlxlXs57Bwp6isOS' +``` + +This will create on startup a new demo user if it does not exist already. Like every parameter, you can also edit `add-user` in the configuration file. diff --git a/internal/config/config.go b/internal/config/config.go index 29644a5..38bfe51 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "errors" "log" "os" + "strings" "github.com/alecthomas/kong" kongtoml "github.com/alecthomas/kong-toml" @@ -11,15 +12,27 @@ import ( ) type Config struct { - Port string `toml:"port" default:"8080" comment:"The port to listen on for the server."` - DatabaseFilePath string `toml:"database-file-path" default:"plm.db" comment:"Path to sqlite database file."` - DemoDataPath string `toml:"demo-data-path" comment:"The path to the sql file to load for demo data."` - JWTKey string `toml:"jwt-key" comment:"The key used to encrypt the generated JWT. Encoded in base64. If empty a random one will be generated on every restart."` - ImageFolderPath string `toml:"image-folder-path" default:"img" comment:"Folder where uploaded files will be stored."` - Limit int `toml:"limit" default:"100" comment:"A single API call will return at most this number of records."` - InventaireUrl string `toml:"inventaire-url" default:"https://inventaire.io" comment:"An inventaire.io instance URL."` - DisableRegistration bool `toml:"disable-registration" default:"false" comment:"Disable new account creation."` - DemoMode bool `toml:"demo-mode" default:"false" comment:"Activate demo mode: anyone connecting to the instance will be logged in as user 'demo'"` + Port string `toml:"port" default:"8080" comment:"The port to listen on for the server."` + DatabaseFilePath string `toml:"database-file-path" default:"plm.db" comment:"Path to sqlite database file."` + DemoDataPath string `toml:"demo-data-path" comment:"The path to the sql file to load for demo data."` + JWTKey string `toml:"jwt-key" comment:"The key used to encrypt the generated JWT. Encoded in base64. If empty a random one will be generated on every restart."` + ImageFolderPath string `toml:"image-folder-path" default:"img" comment:"Folder where uploaded files will be stored."` + Limit int `toml:"limit" default:"100" comment:"A single API call will return at most this number of records."` + InventaireUrl string `toml:"inventaire-url" default:"https://inventaire.io" comment:"An inventaire.io instance URL."` + DisableRegistration bool `toml:"disable-registration" default:"false" comment:"Disable new account creation."` + DemoMode bool `toml:"demo-mode" default:"false" comment:"Activate demo mode: anyone connecting to the instance will be logged in as user 'demo'"` + AddUser UserListAsStrings `toml:"add-user" short:"a" comment:"Add an user on startup following htpasswd bcrypt format, example: [\"demo:$2y$10$UHR2646SZo2W.Rhna7bn5eWNLXWJZ/Sa3oLd9RlxlXs57Bwp6isOS\",\"user:$2y$10$3WYUp.VDpzJRywtrxO1s/uWfUIKpTE4yh5B1d2RCef3hvczYbEWTC\"]"` +} + +type UserListAsStrings []string + +func (u UserListAsStrings) Validate() error { + for _, s := range u { + if strings.Count(s, ":") != 1 { + return errors.New("For adding users, please follow the format [username]:[bcrypt hashed password]") + } + } + return nil } func defaultConfig() Config { @@ -33,6 +46,7 @@ func defaultConfig() Config { InventaireUrl: "https://inventaire.io", DisableRegistration: false, DemoMode: false, + AddUser: []string{}, } } diff --git a/internal/createuser/createuser.go b/internal/createuser/createuser.go new file mode 100644 index 0000000..3a3e9fc --- /dev/null +++ b/internal/createuser/createuser.go @@ -0,0 +1,82 @@ +package createuser + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "git.artlef.fr/PersonalLibraryManager/internal/config" + "git.artlef.fr/PersonalLibraryManager/internal/model" + "git.artlef.fr/PersonalLibraryManager/internal/myvalidator" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// this method will hash the password +func CreateUser(db *gorm.DB, username string, password string) error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + return CreateUserWithHashedPassword(db, username, string(hashedPassword)) +} + +// only call this method with hashed password +func CreateUserWithHashedPassword(db *gorm.DB, username string, hashedPassword string) error { + var existingUser model.User + err := db.Where("name = ?", username).First(&existingUser).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if err == nil { + return myvalidator.HttpError{ + StatusCode: http.StatusInternalServerError, + Err: errors.New("An user with this name already exists."), + } + } + user := model.User{ + Name: username, + Password: hashedPassword, + } + return db.Model(&model.User{}).Save(&user).Error +} + +func CreateDefaultUsers(db *gorm.DB, config *config.Config) error { + usersPasswordMap := make(map[string]string) + var usernames []string + for _, s := range config.AddUser { + splittedString := strings.Split(s, ":") + if len(splittedString) < 2 { + return fmt.Errorf("Error when creating user from string %s", s) + } + usernames = append(usernames, splittedString[0]) + usersPasswordMap[splittedString[0]] = splittedString[1] + } + + var existingUsers []model.User + err := db.Where("name IN ?", usernames).Find(&existingUsers).Error + if err != nil { + return err + } + + for _, username := range usernames { + if isInExistingUsers(username, existingUsers) { + continue + } + err = CreateUserWithHashedPassword(db, username, usersPasswordMap[username]) + if err != nil { + return err + } + } + return nil +} + +func isInExistingUsers(username string, existingUsers []model.User) bool { + for _, existingUser := range existingUsers { + if username == existingUser.Name { + return true + } + } + return false +} diff --git a/internal/routes/usersignup.go b/internal/routes/usersignup.go index 5568778..3935843 100644 --- a/internal/routes/usersignup.go +++ b/internal/routes/usersignup.go @@ -5,11 +5,9 @@ import ( "net/http" "git.artlef.fr/PersonalLibraryManager/internal/appcontext" + "git.artlef.fr/PersonalLibraryManager/internal/createuser" "git.artlef.fr/PersonalLibraryManager/internal/dto" - "git.artlef.fr/PersonalLibraryManager/internal/model" "git.artlef.fr/PersonalLibraryManager/internal/myvalidator" - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" ) func PostSignupHandler(ac appcontext.AppContext) { @@ -27,43 +25,10 @@ func PostSignupHandler(ac appcontext.AppContext) { myvalidator.ReturnErrorsAsJsonResponse(&ac, err) return } - userDb, err := userWsToDb(user) - if err != nil { - myvalidator.ReturnErrorsAsJsonResponse(&ac, err) - return - } - - var existingUser model.User - err = ac.Db.Where("name = ?", user.Username).First(&existingUser).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - myvalidator.ReturnErrorsAsJsonResponse(&ac, err) - return - } - if err == nil { - myvalidator.ReturnErrorsAsJsonResponse(&ac, - myvalidator.HttpError{ - StatusCode: http.StatusInternalServerError, - Err: errors.New("An user with this name already exists."), - }) - return - } - err = ac.Db.Model(&model.User{}).Save(&userDb).Error + err = createuser.CreateUser(ac.Db, user.Username, user.Password) if err != nil { myvalidator.ReturnErrorsAsJsonResponse(&ac, err) return } ac.C.String(200, "Success") } - -func userWsToDb(u dto.UserSignup) (model.User, error) { - user := model.User{ - Name: u.Username, - Password: "", - } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) - if err != nil { - return user, err - } - user.Password = string(hashedPassword) - return user, nil -} diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 1dfb64d..2eb7622 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -11,6 +11,7 @@ import ( "git.artlef.fr/PersonalLibraryManager/front" "git.artlef.fr/PersonalLibraryManager/internal/appcontext" "git.artlef.fr/PersonalLibraryManager/internal/config" + "git.artlef.fr/PersonalLibraryManager/internal/createuser" "git.artlef.fr/PersonalLibraryManager/internal/db" i18nresource "git.artlef.fr/PersonalLibraryManager/internal/i18nresource" "git.artlef.fr/PersonalLibraryManager/internal/jwtauth" @@ -24,6 +25,10 @@ func Setup(config *config.Config) *gin.Engine { if err != nil { panic(err) } + err = createuser.CreateDefaultUsers(db, config) + if err != nil { + panic(err) + } r := gin.Default() bundle := i18nresource.InitializeI18n()