From ff8604eac14f3711405f44b5a09726d346db103f Mon Sep 17 00:00:00 2001 From: Arthur Lefebvre Date: Tue, 28 Apr 2026 19:50:35 +0200 Subject: [PATCH] Add admin user, and an option to add a user admin on startup --- README.md | 2 + demodata.sql | 1 + internal/apitest/get_appinfo_test.go | 1 + internal/config/config.go | 1 + internal/createuser/createuser.go | 84 +++++++++++++++++++++------- internal/dto/out.go | 1 + internal/jwtauth/jwt.go | 9 ++- internal/middleware/auth.go | 62 ++++++++++++++------ internal/model/user.go | 6 +- internal/routes/appinfo.go | 12 ++++ internal/routes/userlogin.go | 17 +++--- 11 files changed, 144 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 5770419..99985c5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ Or with a volume, for example if you created a volume named `bibliomane_data`: `--add-user` or `-a` can be used to create an account on startup. It requires a string following htpasswd format `[username]:[bcrypt hashed password]`. +Use `--add-admin-user` or `-A` instead to add an user as an admin. + The password can be generated using `htpasswd -nBC10 [username]`. For example, to create an user account `demo`: diff --git a/demodata.sql b/demodata.sql index 7df7864..4acad53 100644 --- a/demodata.sql +++ b/demodata.sql @@ -1,6 +1,7 @@ --users INSERT INTO users(created_at, name, password) VALUES ('NOW', 'demo','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba'); INSERT INTO users(created_at, name, password) VALUES ('NOW', 'demo2','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba'); +INSERT INTO users(created_at, name, admin, password) VALUES ('NOW', 'admin', 'true', '$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba'); -- cover INSERT INTO static_files(name, path) VALUES ('odingosochateaux.jpg', 'odingosochateaux.jpg'); diff --git a/internal/apitest/get_appinfo_test.go b/internal/apitest/get_appinfo_test.go index 956da2d..10c720c 100644 --- a/internal/apitest/get_appinfo_test.go +++ b/internal/apitest/get_appinfo_test.go @@ -28,4 +28,5 @@ func TestGetAppInfo_Ok(t *testing.T) { assert.Equal(t, false, appInfo.RegistrationDisabled) assert.Equal(t, false, appInfo.DemoMode) + assert.Equal(t, false, appInfo.Admin) } diff --git a/internal/config/config.go b/internal/config/config.go index 5a39cd9..1bc6817 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,7 @@ type Config struct { DemoMode bool `toml:"demo-mode" short:"D" default:"false" help:"Activate demo mode." comment:"Activate demo mode: anyone connecting to the instance will be logged in as a single user."` DemoUsername string `toml:"demo-username" default:"demo" help:"Name of the single user used for the demo." comment:"Name of the single user used for the demo."` AddUser UserListAsStrings `toml:"add-user" short:"a" help:"Add users on startup following htpasswd bcrypt format." comment:"Add users on startup following htpasswd bcrypt format, example: [\"demo:$2y$10$UHR2646SZo2W.Rhna7bn5eWNLXWJZ/Sa3oLd9RlxlXs57Bwp6isOS\",\"user:$2y$10$3WYUp.VDpzJRywtrxO1s/uWfUIKpTE4yh5B1d2RCef3hvczYbEWTC\"]"` + AddAdminUser UserListAsStrings `toml:"add-admin-user" short:"A" help:"Same as add-user but the added user has admin privilege." comment:"Same as add-user but the added user has admin privilege."` } type UserListAsStrings []string diff --git a/internal/createuser/createuser.go b/internal/createuser/createuser.go index d9ffeaf..9b33607 100644 --- a/internal/createuser/createuser.go +++ b/internal/createuser/createuser.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "net/http" - "slices" "strings" "git.artlef.fr/bibliomane/internal/appcontext" @@ -21,13 +20,19 @@ func CreateUser(ac appcontext.AppContext, username string, password string) erro if err != nil { return err } - return CreateUserWithHashedPassword(ac, username, string(hashedPassword)) + + user := model.User{ + Name: username, + Password: string(hashedPassword), + Admin: false, + } + return CreateUserWithHashedPassword(ac, &user) } // only call this method with hashed password -func CreateUserWithHashedPassword(ac appcontext.AppContext, username string, hashedPassword string) error { +func CreateUserWithHashedPassword(ac appcontext.AppContext, userToCreate *model.User) error { var existingUser model.User - err := ac.Db.Where("name = ?", username).First(&existingUser).Error + err := ac.Db.Where("name = ?", userToCreate.Name).First(&existingUser).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } @@ -37,41 +42,72 @@ func CreateUserWithHashedPassword(ac appcontext.AppContext, username string, has Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "UserAlreadyExists")), } } - user := model.User{ - Name: username, - Password: hashedPassword, - } - return ac.Db.Model(&model.User{}).Save(&user).Error + return ac.Db.Model(&model.User{}).Save(&userToCreate).Error } func CreateDefaultUsers(ac appcontext.AppContext) error { + usersPasswordMap, err := createNormalUsersMap(ac) + if err != nil { + return err + } + err = createDefaultUsersFromMap(ac, usersPasswordMap, false) + if err != nil { + return err + } + adminUsersPasswordMap, err := createDefaultUsersMap(ac, ac.Config.AddAdminUser) + if err != nil { + return err + } + return createDefaultUsersFromMap(ac, adminUsersPasswordMap, true) +} + +func createNormalUsersMap(ac appcontext.AppContext) (map[string]string, error) { + usersPasswordMap, err := createDefaultUsersMap(ac, ac.Config.AddUser) + if err != nil { + return usersPasswordMap, err + } + _, ok := usersPasswordMap[ac.Config.DemoUsername] + if !ok { + usersPasswordMap[ac.Config.DemoUsername] = "" + } + return usersPasswordMap, nil +} + +func createDefaultUsersMap(ac appcontext.AppContext, adduser []string) (map[string]string, error) { usersPasswordMap := make(map[string]string) - var usernames []string - for _, s := range ac.Config.AddUser { + for _, s := range adduser { splittedString := strings.Split(s, ":") if len(splittedString) < 2 { - return fmt.Errorf(i18nresource.GetTranslatedMessage(&ac, "ErrorWhenCreatingUserFromStr"), s) + return usersPasswordMap, + fmt.Errorf(i18nresource.GetTranslatedMessage(&ac, "ErrorWhenCreatingUserFromStr"), s) } - usernames = append(usernames, splittedString[0]) usersPasswordMap[splittedString[0]] = splittedString[1] } - if !slices.Contains(usernames, ac.Config.DemoUsername) { - usernames = append(usernames, ac.Config.DemoUsername) - usersPasswordMap[ac.Config.DemoUsername] = "" + return usersPasswordMap, nil +} - } +func createDefaultUsersFromMap( + ac appcontext.AppContext, + usersPasswordMap map[string]string, + isAdmin bool) error { var existingUsers []model.User - err := ac.Db.Where("name IN ?", usernames).Find(&existingUsers).Error + err := ac.Db.Where("name IN ?", mapToArrayKey(usersPasswordMap)).Find(&existingUsers).Error if err != nil { return err } - for _, username := range usernames { + for username, password := range usersPasswordMap { if isInExistingUsers(username, existingUsers) { continue } - err = CreateUserWithHashedPassword(ac, username, usersPasswordMap[username]) + + user := model.User{ + Name: username, + Password: password, + Admin: isAdmin, + } + err = CreateUserWithHashedPassword(ac, &user) if err != nil { return err } @@ -87,3 +123,11 @@ func isInExistingUsers(username string, existingUsers []model.User) bool { } return false } + +func mapToArrayKey(m map[string]string) []string { + var a []string + for k := range m { + a = append(a, k) + } + return a +} diff --git a/internal/dto/out.go b/internal/dto/out.go index 8769c7a..3531139 100644 --- a/internal/dto/out.go +++ b/internal/dto/out.go @@ -4,6 +4,7 @@ type AppInfo struct { RegistrationDisabled bool `json:"registrationDisabled"` DemoMode bool `json:"demoMode"` DemoUsername string `json:"demoUsername"` + Admin bool `json:"admin"` } type FullBookGet struct { diff --git a/internal/jwtauth/jwt.go b/internal/jwtauth/jwt.go index 8eae055..c837256 100644 --- a/internal/jwtauth/jwt.go +++ b/internal/jwtauth/jwt.go @@ -1,10 +1,12 @@ package jwtauth import ( + "strconv" + "github.com/golang-jwt/jwt/v5" ) -func GenerateJwtToken(username string) (string, error) { +func GenerateJwtToken(username string, admin bool) (string, error) { var s string key, err := GetJwtKey() if err != nil { @@ -12,8 +14,9 @@ func GenerateJwtToken(username string) (string, error) { } t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "iss": "bibliomane", - "sub": username, + "iss": "bibliomane", + "sub": username, + "admin": strconv.FormatBool(admin), }) return t.SignedString(key) } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index b354835..0ae9918 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -2,6 +2,7 @@ package middleware import ( "net/http" + "strconv" "strings" "git.artlef.fr/bibliomane/internal/jwtauth" @@ -27,29 +28,33 @@ func Auth() gin.HandlerFunc { return } - username, err := parseUserFromJwt(c) + jwtokenStr := jwtFromBearerToken(c.GetHeader("Authorization")) + jwtoken, err := jwt.Parse(jwtokenStr, + func(token *jwt.Token) (any, error) { + return jwtauth.GetJwtKey() + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) + if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, - gin.H{"error": "You must be logged in to access this resource."}) + abortError(c) + return + } + + //check admin rights + if strings.HasPrefix(c.FullPath(), "/ws/admin/") && !hasAdminRights(jwtoken) { + c.AbortWithStatusJSON(http.StatusForbidden, + gin.H{"error": "You do not have the right to access this resource."}) + return + } + + username, err := jwtoken.Claims.GetSubject() + if err != nil { + abortError(c) } else { c.Set("user", username) } } } -func parseUserFromJwt(c *gin.Context) (string, error) { - - jwtokenStr := jwtFromBearerToken(c.GetHeader("Authorization")) - jwtoken, parseErr := jwt.Parse(jwtokenStr, - func(token *jwt.Token) (any, error) { - return jwtauth.GetJwtKey() - }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) - if parseErr != nil { - return "", parseErr - } - return jwtoken.Claims.GetSubject() -} - func jwtFromBearerToken(bearerToken string) string { splitToken := strings.Split(bearerToken, " ") if len(splitToken) == 2 { @@ -58,3 +63,28 @@ func jwtFromBearerToken(bearerToken string) string { return "" } } + +func hasAdminRights(token *jwt.Token) bool { + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return false + } + raw, ok := claims["admin"] + if !ok { + return false + } + adminStr, ok := raw.(string) + if !ok { + return false + } + isAdmin, err := strconv.ParseBool(adminStr) + if err != nil { + return false + } + return isAdmin +} + +func abortError(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusUnauthorized, + gin.H{"error": "You must be logged in to access this resource."}) +} diff --git a/internal/model/user.go b/internal/model/user.go index 9bde43e..bc1977d 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -4,7 +4,7 @@ import "gorm.io/gorm" type User struct { gorm.Model - Name string `gorm:"index;uniqueIndex"` - Password string - UserBooks []UserBook + Name string `gorm:"index;uniqueIndex"` + Password string + Admin bool } diff --git a/internal/routes/appinfo.go b/internal/routes/appinfo.go index 9fbfe4d..785666a 100644 --- a/internal/routes/appinfo.go +++ b/internal/routes/appinfo.go @@ -5,12 +5,24 @@ import ( "git.artlef.fr/bibliomane/internal/appcontext" "git.artlef.fr/bibliomane/internal/dto" + "git.artlef.fr/bibliomane/internal/myvalidator" ) func GetAppInfo(ac appcontext.AppContext) { + admin := false + _, userIsInContext := ac.C.Get("user") + if userIsInContext { + user, err := ac.GetAuthenticatedUser() + if err != nil { + myvalidator.ReturnErrorsAsJsonResponse(&ac, err) + return + } + admin = user.Admin + } ac.C.JSON(http.StatusOK, dto.AppInfo{ RegistrationDisabled: ac.Config.DisableRegistration, DemoMode: ac.Config.DemoMode, DemoUsername: ac.Config.DemoUsername, + Admin: admin, }) } diff --git a/internal/routes/userlogin.go b/internal/routes/userlogin.go index e1760a7..3b2674e 100644 --- a/internal/routes/userlogin.go +++ b/internal/routes/userlogin.go @@ -12,12 +12,12 @@ import ( "git.artlef.fr/bibliomane/internal/myvalidator" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" ) func PostLoginHandler(ac appcontext.AppContext) { var username string + admin := false if !ac.Config.DemoMode { var user dto.UserLogin @@ -26,19 +26,23 @@ func PostLoginHandler(ac appcontext.AppContext) { myvalidator.ReturnErrorsAsJsonResponse(&ac, err) return } + var userDb model.User + ac.Db.Where("name = ?", user.Username).First(&userDb) - if !ac.Config.DemoMode && !isUserAndPasswordOk(ac.Db, user.Username, user.Password) { + if !ac.Config.DemoMode && + bcrypt.CompareHashAndPassword([]byte(userDb.Password), []byte(user.Password)) != nil { ac.C.JSON(http.StatusUnauthorized, gin.H{"error": i18nresource.GetTranslatedMessage(&ac, "InvalidCredentials")}) return } username = user.Username + admin = userDb.Admin } else { username = ac.Config.DemoUsername } var jwtToken string - jwtToken, err := jwtauth.GenerateJwtToken(username) + jwtToken, err := jwtauth.GenerateJwtToken(username, admin) if err != nil { ac.C.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Errorf("Error when generating JWT token: %w", err)}) @@ -46,10 +50,3 @@ func PostLoginHandler(ac appcontext.AppContext) { } ac.C.JSON(http.StatusOK, gin.H{"message": i18nresource.GetTranslatedMessage(&ac, "AuthenticationSuccess"), "token": jwtToken}) } - -func isUserAndPasswordOk(db *gorm.DB, username string, password string) bool { - var user model.User - db.Where("name = ?", username).First(&user) - err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) - return err == nil -}