From c14b21cc97238197c3a287d063d18547049dbe9b Mon Sep 17 00:00:00 2001 From: Schattenwelt Date: Wed, 6 May 2026 21:58:38 +0200 Subject: [PATCH 01/14] feat: multi-user authentication with role-based access control --- PR_changes.tar.gz | Bin 0 -> 24782 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 PR_changes.tar.gz diff --git a/PR_changes.tar.gz b/PR_changes.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..64d1126b1c4b23b2da7d33613f378f62a19a1c42 GIT binary patch literal 24782 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 literal 0 HcmV?d00001 From 0a418b71f2806fa950ec67d583c96238e1447fdb Mon Sep 17 00:00:00 2001 From: Schattenwelt Date: Wed, 6 May 2026 22:05:51 +0200 Subject: [PATCH 02/14] fix: remove username field from initial password change --- PR_DESCRIPTION.md | 73 +++++ PR_changes.tar.gz | Bin 24782 -> 0 bytes server/middleware/jwt.go | 84 +++-- server/proto/auth.go | 24 ++ server/router/auth.go | 25 +- server/router/hid.go | 38 +-- server/router/vm.go | 113 ++++--- server/service/auth/account.go | 252 +++++++++++---- server/service/auth/login.go | 31 +- server/service/auth/password.go | 83 +++-- server/service/auth/users.go | 176 ++++++++++ web/src/api/auth.ts | 31 +- web/src/hooks/useRole.ts | 31 ++ web/src/i18n/locales/de.ts | 33 +- web/src/i18n/locales/en.ts | 33 +- web/src/pages/auth/password/index.tsx | 17 +- web/src/pages/desktop/menu/index.tsx | 33 +- web/src/pages/desktop/menu/settings/index.tsx | 61 ++-- .../desktop/menu/settings/users/index.tsx | 304 ++++++++++++++++++ 19 files changed, 1144 insertions(+), 298 deletions(-) create mode 100644 PR_DESCRIPTION.md delete mode 100644 PR_changes.tar.gz create mode 100644 server/service/auth/users.go create mode 100644 web/src/hooks/useRole.ts create mode 100644 web/src/pages/desktop/menu/settings/users/index.tsx diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 00000000..e9899b4d --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,73 @@ +# Multi-User Authentication with Role-Based Access Control + +## Summary + +This PR adds support for multiple users with three distinct roles, replacing the current single-user authentication system. + +## Motivation + +Many users (myself included) want to give different people different levels of access to the NanoKVM: +- **Admins** who configure everything +- **Operators** who use the KVM (control the remote PC) +- **Viewers** who can only watch the screen + +Currently only one shared admin account exists, which is a security and usability limitation. + +## Roles + +| Role | Description | +|------|-------------| +| `admin` | Full access including user management and all system settings | +| `operator` | KVM control: stream, keyboard, mouse, paste, GPIO, terminal, scripts | +| `viewer` | Read-only: stream and basic device info | + +## Changes + +### Backend (Go) +- `server/service/auth/account.go` – New multi-user storage in `/etc/kvm/accounts.json` (bcrypt hashed) +- `server/service/auth/login.go` – Role embedded in JWT claims +- `server/service/auth/password.go` – Permission-aware password changes +- `server/service/auth/users.go` – **NEW** – CRUD endpoints for user management +- `server/middleware/jwt.go` – New `RequireRole()` middleware for fine-grained access control +- `server/proto/auth.go` – New request/response types +- `server/router/auth.go` – New user management endpoints +- `server/router/hid.go` – Role-based access (operator+ for inputs) +- `server/router/vm.go` – Role-based access per endpoint + +### Frontend (React) +- `web/src/hooks/useRole.ts` – **NEW** – Hook to retrieve current user role +- `web/src/pages/desktop/menu/settings/users/index.tsx` – **NEW** – User management UI +- `web/src/pages/desktop/menu/settings/index.tsx` – Tabs filtered by role +- `web/src/pages/desktop/menu/index.tsx` – Menu items hidden based on role +- `web/src/api/auth.ts` – New API functions for user management +- `web/src/i18n/locales/{en,de}.ts` – Translations + +## Backwards compatibility + +The legacy `/etc/kvm/pwd` file is automatically migrated to `/etc/kvm/accounts.json` on first start. The migrated user receives the `admin` role. + +## New API Endpoints + +All require `admin` role except `/api/auth/users/:username/password` (admins can change anyone's password, others only their own). + +``` +GET /api/auth/users +POST /api/auth/users +PUT /api/auth/users/:username +DELETE /api/auth/users/:username +POST /api/auth/users/:username/password +``` + +## Testing + +Tested on NanoKVM-PCIe with NanoKVM v2.4.0: +- ✅ Migration from legacy single-user format works +- ✅ Login with all three roles +- ✅ Role-based UI hiding +- ✅ User CRUD via web interface +- ✅ Last admin cannot be deleted +- ✅ Disabled users cannot log in + +## Screenshot + +(add a screenshot of the user management page here) diff --git a/PR_changes.tar.gz b/PR_changes.tar.gz deleted file mode 100644 index 64d1126b1c4b23b2da7d33613f378f62a19a1c42..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24782 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 diff --git a/server/middleware/jwt.go b/server/middleware/jwt.go index 0891917b..dceb50c9 100644 --- a/server/middleware/jwt.go +++ b/server/middleware/jwt.go @@ -11,19 +11,51 @@ import ( "NanoKVM-Server/config" ) +// Role constants mirrored here to avoid circular imports. +const ( + RoleAdmin = "admin" + RoleOperator = "operator" + RoleViewer = "viewer" +) + type Token struct { Username string `json:"username"` + Role string `json:"role"` jwt.RegisteredClaims } +// CheckToken allows any authenticated user. func CheckToken() gin.HandlerFunc { return func(c *gin.Context) { - if allowByToken(c) { - c.Next() + token, ok := parseTokenFromContext(c) + if !ok { + abortUnauthorized(c) return } + // Store username and role for downstream handlers. + c.Set("username", token.Username) + c.Set("role", token.Role) + c.Next() + } +} - abortUnauthorized(c) +// RequireRole returns a middleware that only allows users with one of the given roles. +func RequireRole(roles ...string) gin.HandlerFunc { + allowed := make(map[string]bool, len(roles)) + for _, r := range roles { + allowed[r] = true + } + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists { + abortForbidden(c) + return + } + if !allowed[role.(string)] { + abortForbidden(c) + return + } + c.Next() } } @@ -33,36 +65,43 @@ func CheckLoopbackInternalToken() gin.HandlerFunc { c.Next() return } - abortUnauthorized(c) } } func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc { return func(c *gin.Context) { - if allowByToken(c) || allowByLoopbackInternalToken(c.Request) { + token, ok := parseTokenFromContext(c) + if ok { + c.Set("username", token.Username) + c.Set("role", token.Role) + c.Next() + return + } + if allowByLoopbackInternalToken(c.Request) { c.Next() return } - abortUnauthorized(c) } } -func allowByToken(c *gin.Context) bool { +func parseTokenFromContext(c *gin.Context) (*Token, bool) { conf := config.GetInstance() - if conf.Authentication == "disable" { - return true + c.Set("username", "admin") + c.Set("role", RoleAdmin) + return &Token{Username: "admin", Role: RoleAdmin}, true } - cookie, err := c.Cookie("nano-kvm-token") if err != nil { - return false + return nil, false } - - _, err = ParseJWT(cookie) - return err == nil + token, err := ParseJWT(cookie) + if err != nil { + return nil, false + } + return token, true } func abortUnauthorized(c *gin.Context) { @@ -70,26 +109,27 @@ func abortUnauthorized(c *gin.Context) { c.Abort() } -func GenerateJWT(username string) (string, error) { - conf := config.GetInstance() +func abortForbidden(c *gin.Context) { + c.JSON(http.StatusForbidden, "forbidden: insufficient permissions") + c.Abort() +} +func GenerateJWT(username, role string) (string, error) { + conf := config.GetInstance() expireDuration := time.Duration(conf.JWT.RefreshTokenDuration) * time.Second - claims := Token{ Username: username, + Role: role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(expireDuration)), }, } - t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return t.SignedString([]byte(conf.JWT.SecretKey)) } func ParseJWT(jwtToken string) (*Token, error) { conf := config.GetInstance() - t, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (interface{}, error) { return []byte(conf.JWT.SecretKey), nil }) @@ -97,10 +137,8 @@ func ParseJWT(jwtToken string) (*Token, error) { log.Debugf("parse jwt error: %s", err) return nil, err } - if claims, ok := t.Claims.(*Token); ok && t.Valid { return claims, nil - } else { - return nil, err } + return nil, err } diff --git a/server/proto/auth.go b/server/proto/auth.go index dcf92cff..828799b4 100644 --- a/server/proto/auth.go +++ b/server/proto/auth.go @@ -11,6 +11,7 @@ type LoginRsp struct { type GetAccountRsp struct { Username string `json:"username"` + Role string `json:"role"` } type ChangePasswordReq struct { @@ -21,3 +22,26 @@ type ChangePasswordReq struct { type IsPasswordUpdatedRsp struct { IsUpdated bool `json:"isUpdated"` } + +// --- Multi-user management --- + +type UserInfo struct { + Username string `json:"username"` + Role string `json:"role"` + Enabled bool `json:"enabled"` +} + +type ListUsersRsp struct { + Users []UserInfo `json:"users"` +} + +type CreateUserReq struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` + Role string `json:"role" validate:"required"` +} + +type UpdateUserReq struct { + Role string `json:"role"` + Enabled *bool `json:"enabled"` +} diff --git a/server/router/auth.go b/server/router/auth.go index aa237fa0..8b0acda3 100644 --- a/server/router/auth.go +++ b/server/router/auth.go @@ -10,12 +10,27 @@ import ( func authRouter(r *gin.Engine) { service := auth.NewService() - r.POST("/api/auth/login", service.Login) // login + // Public – no token required + r.POST("/api/auth/login", service.Login) + // Any authenticated user api := r.Group("/api").Use(middleware.CheckToken()) + api.GET("/auth/password", service.IsPasswordUpdated) + api.POST("/auth/password", service.ChangePassword) + api.GET("/auth/account", service.GetAccount) + api.POST("/auth/logout", service.Logout) - api.GET("/auth/password", service.IsPasswordUpdated) // is password updated - api.GET("/auth/account", service.GetAccount) // get account - api.POST("/auth/password", service.ChangePassword) // change password - api.POST("/auth/logout", service.Logout) // logout + // Any authenticated user may change their own password; + // admin may change any user's password (enforced inside handler). + api.POST("/auth/users/:username/password", service.ChangeUserPassword) + + // Admin-only: full user management + adminAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin), + ) + adminAPI.GET("/auth/users", service.ListUsers) + adminAPI.POST("/auth/users", service.CreateUser) + adminAPI.PUT("/auth/users/:username", service.UpdateUser) + adminAPI.DELETE("/auth/users/:username", service.DeleteUser) } diff --git a/server/router/hid.go b/server/router/hid.go index 665a57da..07feccec 100644 --- a/server/router/hid.go +++ b/server/router/hid.go @@ -7,29 +7,31 @@ import ( "NanoKVM-Server/service/hid" ) -const internalUSBRecoverPath = "/api/internal/usb/recover" - -func HIDLoopbackHTTPAllowedPaths() []string { - return []string{internalUSBRecoverPath} -} - func hidRouter(r *gin.Engine) { service := hid.NewService() - api := r.Group("/api").Use(middleware.CheckToken()) - localAPI := r.Group("/api/internal").Use(middleware.CheckLoopbackInternalToken()) - api.POST("/hid/paste", service.Paste) // paste + // Operator and admin may send inputs + opAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin, middleware.RoleOperator), + ) + + opAPI.POST("/hid/paste", service.Paste) - api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts - api.POST("/hid/shortcut", service.AddShortcut) // add shortcut - api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut + opAPI.GET("/hid/shortcuts", service.GetShortcuts) + opAPI.POST("/hid/shortcut", service.AddShortcut) + opAPI.DELETE("/hid/shortcut", service.DeleteShortcut) - api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key - api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key + opAPI.GET("/hid/shortcut/leader-key", service.GetLeaderKey) + opAPI.POST("/hid/shortcut/leader-key", service.SetLeaderKey) - api.GET("/hid/mode", service.GetHidMode) // get hid mode - api.POST("/hid/mode", service.SetHidMode) // set hid mode - api.POST("/hid/reset", service.ResetHid) // reset hid + opAPI.GET("/hid/mode", service.GetHidMode) - localAPI.POST("/usb/recover", service.RecoverUSB) + // Admin only: HID hardware configuration + adminAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin), + ) + adminAPI.POST("/hid/mode", service.SetHidMode) + adminAPI.POST("/hid/reset", service.ResetHid) } diff --git a/server/router/vm.go b/server/router/vm.go index f41b6b6e..0cd40ac1 100644 --- a/server/router/vm.go +++ b/server/router/vm.go @@ -10,63 +10,58 @@ import ( func vmRouter(r *gin.Engine) { service := vm.NewService() - api := r.Group("/api").Use(middleware.CheckToken()) - - api.GET("/vm/info", service.GetInfo) // get device information - api.GET("/vm/hardware", service.GetHardware) // get hardware version - - api.POST("/vm/gpio", service.SetGpio) // update gpio - api.GET("/vm/gpio", service.GetGpio) // get gpio - api.POST("/vm/screen", service.SetScreen) // update screen - - api.GET("/vm/terminal", service.Terminal) // web terminal - - api.GET("/vm/script", service.GetScripts) // get script - api.POST("/vm/script/upload", service.UploadScript) // upload script - api.POST("/vm/script/run", service.RunScript) // run script - api.DELETE("/vm/script", service.DeleteScript) // delete script - - api.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device - api.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device - - api.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit - api.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit - - api.GET("/vm/oled", service.GetOLED) // get OLED configuration - api.POST("/vm/oled", service.SetOLED) // set OLED configuration - - // Only supported by PCIe version - api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state - api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi - api.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi - api.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi - - api.GET("/vm/ssh", service.GetSSHState) // get SSH state - api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH - api.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH - - api.GET("/vm/swap", service.GetSwap) // get swap file size - api.POST("/vm/swap", service.SetSwap) // set swap file size - - api.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler - api.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) // set mouse jiggler - - api.GET("/vm/hostname", service.GetHostname) // Get Hostname - api.POST("/vm/hostname", service.SetHostname) // Set Hostname - - api.GET("/vm/web-title", service.GetWebTitle) // Get web title - api.POST("/vm/web-title", service.SetWebTitle) // Set web title - - api.GET("/vm/mdns", service.GetMdnsState) // get mDNS state - api.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS - api.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS - - api.POST("/vm/tls", service.SetTls) // enable/disable TLS - - api.GET("/vm/autostart", service.GetAutostart) // get autostart list - api.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content - api.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script - api.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script - - api.POST("/vm/system/reboot", service.Reboot) // reboot system + // All authenticated users (viewer, operator, admin) may read basic info + anyAPI := r.Group("/api").Use(middleware.CheckToken()) + anyAPI.GET("/vm/info", service.GetInfo) + anyAPI.GET("/vm/hardware", service.GetHardware) + anyAPI.GET("/vm/gpio", service.GetGpio) + anyAPI.GET("/vm/device/virtual", service.GetVirtualDevice) + anyAPI.GET("/vm/memory/limit", service.GetMemoryLimit) + anyAPI.GET("/vm/oled", service.GetOLED) + anyAPI.GET("/vm/hdmi", service.GetHdmiState) + anyAPI.GET("/vm/ssh", service.GetSSHState) + anyAPI.GET("/vm/swap", service.GetSwap) + anyAPI.GET("/vm/mouse-jiggler", service.GetMouseJiggler) + anyAPI.GET("/vm/hostname", service.GetHostname) + anyAPI.GET("/vm/web-title", service.GetWebTitle) + anyAPI.GET("/vm/mdns", service.GetMdnsState) + anyAPI.GET("/vm/autostart", service.GetAutostart) + anyAPI.GET("/vm/autostart/:name", service.GetAutostartContent) + + // Operator and admin may interact with the machine + opAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin, middleware.RoleOperator), + ) + opAPI.POST("/vm/gpio", service.SetGpio) // power/reset buttons + opAPI.GET("/vm/terminal", service.Terminal) // web terminal + opAPI.GET("/vm/script", service.GetScripts) + opAPI.POST("/vm/script/run", service.RunScript) + opAPI.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) + + // Admin only: system configuration + adminAPI := r.Group("/api").Use( + middleware.CheckToken(), + middleware.RequireRole(middleware.RoleAdmin), + ) + adminAPI.POST("/vm/screen", service.SetScreen) + adminAPI.POST("/vm/script/upload", service.UploadScript) + adminAPI.DELETE("/vm/script", service.DeleteScript) + adminAPI.POST("/vm/device/virtual", service.UpdateVirtualDevice) + adminAPI.POST("/vm/memory/limit", service.SetMemoryLimit) + adminAPI.POST("/vm/oled", service.SetOLED) + adminAPI.POST("/vm/hdmi/reset", service.ResetHdmi) + adminAPI.POST("/vm/hdmi/enable", service.EnableHdmi) + adminAPI.POST("/vm/hdmi/disable", service.DisableHdmi) + adminAPI.POST("/vm/ssh/enable", service.EnableSSH) + adminAPI.POST("/vm/ssh/disable", service.DisableSSH) + adminAPI.POST("/vm/swap", service.SetSwap) + adminAPI.POST("/vm/hostname", service.SetHostname) + adminAPI.POST("/vm/web-title", service.SetWebTitle) + adminAPI.POST("/vm/mdns/enable", service.EnableMdns) + adminAPI.POST("/vm/mdns/disable", service.DisableMdns) + adminAPI.POST("/vm/tls", service.SetTls) + adminAPI.DELETE("/vm/autostart/:name", service.DeleteAutostart) + adminAPI.POST("/vm/autostart/:name", service.UploadAutostart) + adminAPI.POST("/vm/system/reboot", service.Reboot) } diff --git a/server/service/auth/account.go b/server/service/auth/account.go index 0305965e..76c7f063 100644 --- a/server/service/auth/account.go +++ b/server/service/auth/account.go @@ -1,7 +1,6 @@ package auth import ( - "NanoKVM-Server/utils" "encoding/json" "errors" "os" @@ -9,105 +8,248 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/crypto/bcrypt" + + "NanoKVM-Server/utils" ) -const AccountFile = "/etc/kvm/pwd" +const AccountFile = "/etc/kvm/accounts.json" +const LegacyAccountFile = "/etc/kvm/pwd" +// Role defines the permission level of a user. +type Role string + +const ( + RoleAdmin Role = "admin" // Full access including user management + RoleOperator Role = "operator" // KVM control: stream, keyboard, mouse, GPIO + RoleViewer Role = "viewer" // View-only: stream access +) + +// Account represents a single user. type Account struct { Username string `json:"username"` - Password string `json:"password"` // should be named HashedPassword for clarity + Password string `json:"password"` // bcrypt hash + Role Role `json:"role"` + Enabled bool `json:"enabled"` } -func GetAccount() (*Account, error) { - if _, err := os.Stat(AccountFile); err != nil { - if errors.Is(err, os.ErrNotExist) { - return getDefaultAccount(), nil - } - return nil, err +// legacyAccount mirrors the old single-user format for migration. +type legacyAccount struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// GetAccounts returns all accounts, migrating from legacy format if needed. +func GetAccounts() ([]Account, error) { + if _, err := os.Stat(AccountFile); err == nil { + return readAccountsFile() + } + if _, err := os.Stat(LegacyAccountFile); err == nil { + return migrateLegacyAccount() } + return []Account{defaultAdminAccount()}, nil +} - content, err := os.ReadFile(AccountFile) +// GetAccountByUsername returns a specific account or an error if not found. +func GetAccountByUsername(username string) (*Account, error) { + accounts, err := GetAccounts() if err != nil { return nil, err } - - var account Account - if err = json.Unmarshal(content, &account); err != nil { - log.Errorf("unmarshal account failed: %s", err) - return nil, err + for _, a := range accounts { + if a.Username == username { + acc := a + return &acc, nil + } } - - return &account, nil + return nil, errors.New("user not found") } -func SetAccount(username string, hashedPassword string) error { - account, err := json.Marshal(&Account{ - Username: username, - Password: hashedPassword, - }) +// SaveAccounts writes the full account list to disk. +func SaveAccounts(accounts []Account) error { + data, err := json.MarshalIndent(accounts, "", " ") if err != nil { - log.Errorf("failed to marshal account information to json: %s", err) return err } + if err = os.MkdirAll(filepath.Dir(AccountFile), 0o755); err != nil { + return err + } + return os.WriteFile(AccountFile, data, 0o600) +} - err = os.MkdirAll(filepath.Dir(AccountFile), 0o644) +// AddAccount appends a new user. Returns error if username exists. +func AddAccount(username, plainPassword string, role Role) error { + accounts, err := GetAccounts() if err != nil { - log.Errorf("create directory %s failed: %s", AccountFile, err) return err } - - err = os.WriteFile(AccountFile, account, 0o644) + for _, a := range accounts { + if a.Username == username { + return errors.New("username already exists") + } + } + hashed, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost) if err != nil { - log.Errorf("write password failed: %s", err) return err } - - return nil + accounts = append(accounts, Account{ + Username: username, + Password: string(hashed), + Role: role, + Enabled: true, + }) + return SaveAccounts(accounts) } -func CompareAccount(username string, plainPassword string) bool { - account, err := GetAccount() +// UpdateAccountPassword changes a user's password (expects bcrypt hash). +func UpdateAccountPassword(username, hashedPassword string) error { + accounts, err := GetAccounts() if err != nil { - return false + return err + } + for i, a := range accounts { + if a.Username == username { + accounts[i].Password = hashedPassword + return SaveAccounts(accounts) + } } + return errors.New("user not found") +} - if username != account.Username { - return false +// UpdateAccountRole changes a user's role. +func UpdateAccountRole(username string, role Role) error { + accounts, err := GetAccounts() + if err != nil { + return err } + for i, a := range accounts { + if a.Username == username { + accounts[i].Role = role + return SaveAccounts(accounts) + } + } + return errors.New("user not found") +} - hashedPassword, err := utils.DecodeDecrypt(plainPassword) - if err != nil || hashedPassword == "" { - return false +// SetAccountEnabled enables or disables a user account. +func SetAccountEnabled(username string, enabled bool) error { + accounts, err := GetAccounts() + if err != nil { + return err } + for i, a := range accounts { + if a.Username == username { + accounts[i].Enabled = enabled + return SaveAccounts(accounts) + } + } + return errors.New("user not found") +} - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(hashedPassword)) +// DeleteAccount removes a user. The last admin account cannot be deleted. +func DeleteAccount(username string) error { + accounts, err := GetAccounts() if err != nil { - // Compatible with old versions - accountHashedPassword, _ := utils.DecodeDecrypt(account.Password) - if accountHashedPassword == hashedPassword { - return true + return err + } + var target *Account + for _, a := range accounts { + if a.Username == username { + acc := a + target = &acc + break } + } + if target == nil { + return errors.New("user not found") + } + if target.Role == RoleAdmin { + adminCount := 0 + for _, a := range accounts { + if a.Role == RoleAdmin && a.Enabled { + adminCount++ + } + } + if adminCount <= 1 { + return errors.New("cannot delete the last admin account") + } + } + filtered := make([]Account, 0, len(accounts)-1) + for _, a := range accounts { + if a.Username != username { + filtered = append(filtered, a) + } + } + return SaveAccounts(filtered) +} - return false +// CompareAccount checks credentials and returns the account on success. +func CompareAccount(username, plainPassword string) (*Account, bool) { + account, err := GetAccountByUsername(username) + if err != nil || account == nil || !account.Enabled { + return nil, false } + decoded, err := utils.DecodeDecrypt(plainPassword) + if err != nil || decoded == "" { + return nil, false + } + if err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(decoded)); err != nil { + // Compatibility with old plain-hashed storage + oldHash, _ := utils.DecodeDecrypt(account.Password) + if oldHash != decoded { + return nil, false + } + } + return account, true +} - return true +// IsValidRole checks whether a role string is valid. +func IsValidRole(r Role) bool { + return r == RoleAdmin || r == RoleOperator || r == RoleViewer } -func DelAccount() error { - if err := os.Remove(AccountFile); err != nil { - log.Errorf("failed to delete password: %s", err) - return err +func readAccountsFile() ([]Account, error) { + data, err := os.ReadFile(AccountFile) + if err != nil { + return nil, err } - - return nil + var accounts []Account + if err = json.Unmarshal(data, &accounts); err != nil { + log.Errorf("failed to unmarshal accounts: %s", err) + return nil, err + } + return accounts, nil } -func getDefaultAccount() *Account { - hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) +func migrateLegacyAccount() ([]Account, error) { + data, err := os.ReadFile(LegacyAccountFile) + if err != nil { + return nil, err + } + var legacy legacyAccount + if err = json.Unmarshal(data, &legacy); err != nil { + log.Errorf("failed to unmarshal legacy account: %s", err) + return []Account{defaultAdminAccount()}, nil + } + account := Account{ + Username: legacy.Username, + Password: legacy.Password, + Role: RoleAdmin, + Enabled: true, + } + accounts := []Account{account} + if saveErr := SaveAccounts(accounts); saveErr == nil { + _ = os.Remove(LegacyAccountFile) + log.Infof("migrated legacy account '%s' to multi-user format", legacy.Username) + } + return accounts, nil +} - return &Account{ +func defaultAdminAccount() Account { + hashed, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) + return Account{ Username: "admin", - Password: string(hashedPassword), + Password: string(hashed), + Role: RoleAdmin, + Enabled: true, } } diff --git a/server/service/auth/login.go b/server/service/auth/login.go index 9e566d66..48344cc8 100644 --- a/server/service/auth/login.go +++ b/server/service/auth/login.go @@ -15,12 +15,9 @@ func (s *Service) Login(c *gin.Context) { var req proto.LoginReq var rsp proto.Response - // authentication disabled conf := config.GetInstance() if conf.Authentication == "disable" { - rsp.OkRspWithData(c, &proto.LoginRsp{ - Token: "disabled", - }) + rsp.OkRspWithData(c, &proto.LoginRsp{Token: "disabled"}) return } @@ -37,41 +34,35 @@ func (s *Service) Login(c *gin.Context) { return } - if ok := CompareAccount(req.Username, req.Password); !ok { + account, ok := CompareAccount(req.Username, req.Password) + if !ok { time.Sleep(2 * time.Second) - if locked, code, msg := RecordLoginFailure(clientIP); locked { rsp.ErrRsp(c, code, msg) return } - rsp.ErrRsp(c, -2, "invalid username or password") return } ClearLoginAttempt(clientIP) - token, err := middleware.GenerateJWT(req.Username) + token, err := middleware.GenerateJWT(account.Username, string(account.Role)) if err != nil { time.Sleep(1 * time.Second) rsp.ErrRsp(c, -3, "generate token failed") return } - rsp.OkRspWithData(c, &proto.LoginRsp{ - Token: token, - }) - - log.Debugf("login success, username: %s", req.Username) + rsp.OkRspWithData(c, &proto.LoginRsp{Token: token}) + log.Debugf("login success, username: %s, role: %s", account.Username, account.Role) } func (s *Service) Logout(c *gin.Context) { conf := config.GetInstance() - if conf.JWT.RevokeTokensOnLogout { config.RegenerateSecretKey() } - var rsp proto.Response rsp.OkRsp(c) } @@ -79,14 +70,12 @@ func (s *Service) Logout(c *gin.Context) { func (s *Service) GetAccount(c *gin.Context) { var rsp proto.Response - account, err := GetAccount() - if err != nil { - rsp.ErrRsp(c, -1, "get account failed") - return - } + username, _ := c.Get("username") + role, _ := c.Get("role") rsp.OkRspWithData(c, &proto.GetAccountRsp{ - Username: account.Username, + Username: username.(string), + Role: role.(string), }) log.Debugf("get account successful") } diff --git a/server/service/auth/password.go b/server/service/auth/password.go index 22c60161..c9461b99 100644 --- a/server/service/auth/password.go +++ b/server/service/auth/password.go @@ -1,19 +1,22 @@ package auth import ( - "NanoKVM-Server/proto" - "NanoKVM-Server/utils" "errors" "io" "os" "os/exec" "time" + "NanoKVM-Server/proto" + "NanoKVM-Server/utils" + "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" "golang.org/x/crypto/bcrypt" ) +// ChangePassword allows a user to change their own password (legacy endpoint). +// If the request contains no username, the current logged-in user is used. func (s *Service) ChangePassword(c *gin.Context) { var req proto.ChangePasswordReq var rsp proto.Response @@ -23,57 +26,61 @@ func (s *Service) ChangePassword(c *gin.Context) { return } + selfUsername, _ := c.Get("username") + selfRole, _ := c.Get("role") + + // If no username given, default to the current user. + if req.Username == "" { + req.Username = selfUsername.(string) + } + + // Only admins may change other accounts; others can only change their own. + if selfRole.(string) != "admin" && selfUsername.(string) != req.Username { + rsp.ErrRsp(c, -2, "permission denied") + return + } + password, err := utils.DecodeDecrypt(req.Password) if err != nil || password == "" { - rsp.ErrRsp(c, -2, "invalid password") + rsp.ErrRsp(c, -3, "invalid password") return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - rsp.ErrRsp(c, -3, "failed to hash password") + rsp.ErrRsp(c, -4, "failed to hash password") return } - if err = SetAccount(req.Username, string(hashedPassword)); err != nil { - rsp.ErrRsp(c, -4, "failed to save password") + if err = UpdateAccountPassword(req.Username, string(hashedPassword)); err != nil { + rsp.ErrRsp(c, -5, "failed to save password") return } - // change root password - err = changeRootPassword(password) - if err != nil { - _ = DelAccount() - rsp.ErrRsp(c, -5, "failed to change password") - return + // Only change the root system password when the admin changes their own password. + if req.Username == "admin" { + if err = changeRootPassword(password); err != nil { + log.Warnf("failed to change root password: %s", err) + } } rsp.OkRsp(c) - log.Debugf("change password success, username: %s", req.Username) + log.Debugf("password changed for user: %s", req.Username) } +// IsPasswordUpdated reports whether the admin password has been changed from the default. func (s *Service) IsPasswordUpdated(c *gin.Context) { var rsp proto.Response - if _, err := os.Stat(AccountFile); err != nil { - rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{ - IsUpdated: false, - }) - return - } - - account, err := GetAccount() - if err != nil || account == nil { - rsp.ErrRsp(c, -1, "failed to get password") + account, err := GetAccountByUsername("admin") + if err != nil { + rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{IsUpdated: false}) return } - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte("admin")) - + checkErr := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte("admin")) rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{ - // If the hash is not valid, still assume it's not updated - // The error we want to see is password and hash not matching - IsUpdated: errors.Is(err, bcrypt.ErrMismatchedHashAndPassword), + IsUpdated: errors.Is(checkErr, bcrypt.ErrMismatchedHashAndPassword), }) } @@ -83,42 +90,28 @@ func changeRootPassword(password string) error { log.Errorf("failed to change root password: %s", err) return err } - - log.Debugf("change root password successful.") + log.Debugf("root password changed successfully") return nil } func passwd(password string) error { cmd := exec.Command("passwd", "root") - stdin, err := cmd.StdinPipe() if err != nil { return err } - defer func() { - _ = stdin.Close() - }() - + defer func() { _ = stdin.Close() }() cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - if err = cmd.Start(); err != nil { return err } - if _, err = io.WriteString(stdin, password+"\n"); err != nil { return err } - time.Sleep(100 * time.Millisecond) - if _, err = io.WriteString(stdin, password+"\n"); err != nil { return err } - - if err = cmd.Wait(); err != nil { - return err - } - - return nil + return cmd.Wait() } diff --git a/server/service/auth/users.go b/server/service/auth/users.go new file mode 100644 index 00000000..e252d457 --- /dev/null +++ b/server/service/auth/users.go @@ -0,0 +1,176 @@ +package auth + +import ( + "NanoKVM-Server/proto" + "NanoKVM-Server/utils" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/bcrypt" +) + +// ListUsers returns all user accounts (passwords excluded). +func (s *Service) ListUsers(c *gin.Context) { + var rsp proto.Response + + accounts, err := GetAccounts() + if err != nil { + rsp.ErrRsp(c, -1, "failed to load users") + return + } + + users := make([]proto.UserInfo, 0, len(accounts)) + for _, a := range accounts { + users = append(users, proto.UserInfo{ + Username: a.Username, + Role: string(a.Role), + Enabled: a.Enabled, + }) + } + + rsp.OkRspWithData(c, &proto.ListUsersRsp{Users: users}) +} + +// CreateUser adds a new user (admin only). +func (s *Service) CreateUser(c *gin.Context) { + var req proto.CreateUserReq + var rsp proto.Response + + if err := proto.ParseFormRequest(c, &req); err != nil { + rsp.ErrRsp(c, -1, "invalid parameters") + return + } + + role := Role(req.Role) + if !IsValidRole(role) { + rsp.ErrRsp(c, -2, "invalid role; must be admin, operator, or viewer") + return + } + + password, err := utils.DecodeDecrypt(req.Password) + if err != nil || password == "" { + rsp.ErrRsp(c, -3, "invalid password") + return + } + + if err = AddAccount(req.Username, password, role); err != nil { + rsp.ErrRsp(c, -4, err.Error()) + return + } + + rsp.OkRsp(c) + log.Infof("user created: %s (role: %s)", req.Username, role) +} + +// UpdateUser changes a user's role or enabled status (admin only). +func (s *Service) UpdateUser(c *gin.Context) { + var req proto.UpdateUserReq + var rsp proto.Response + + username := c.Param("username") + if username == "" { + rsp.ErrRsp(c, -1, "username is required") + return + } + + if err := proto.ParseFormRequest(c, &req); err != nil { + rsp.ErrRsp(c, -2, "invalid parameters") + return + } + + if req.Role != "" { + role := Role(req.Role) + if !IsValidRole(role) { + rsp.ErrRsp(c, -3, "invalid role; must be admin, operator, or viewer") + return + } + if err := UpdateAccountRole(username, role); err != nil { + rsp.ErrRsp(c, -4, err.Error()) + return + } + log.Infof("user role updated: %s -> %s", username, role) + } + + if req.Enabled != nil { + if err := SetAccountEnabled(username, *req.Enabled); err != nil { + rsp.ErrRsp(c, -5, err.Error()) + return + } + log.Infof("user enabled state updated: %s -> %v", username, *req.Enabled) + } + + rsp.OkRsp(c) +} + +// DeleteUser removes a user account (admin only). +func (s *Service) DeleteUser(c *gin.Context) { + var rsp proto.Response + + username := c.Param("username") + if username == "" { + rsp.ErrRsp(c, -1, "username is required") + return + } + + // Prevent an admin from deleting themselves. + selfUsername, _ := c.Get("username") + if selfUsername.(string) == username { + rsp.ErrRsp(c, -2, "cannot delete your own account") + return + } + + if err := DeleteAccount(username); err != nil { + rsp.ErrRsp(c, -3, err.Error()) + return + } + + rsp.OkRsp(c) + log.Infof("user deleted: %s", username) +} + +// ChangeUserPassword allows an admin to set any user's password, +// or a user to change their own password. +func (s *Service) ChangeUserPassword(c *gin.Context) { + var req proto.ChangePasswordReq + var rsp proto.Response + + username := c.Param("username") + if username == "" { + rsp.ErrRsp(c, -1, "username is required") + return + } + + selfUsername, _ := c.Get("username") + selfRole, _ := c.Get("role") + + // Only admins may change other users' passwords. + if selfRole.(string) != "admin" && selfUsername.(string) != username { + rsp.ErrRsp(c, -2, "permission denied") + return + } + + if err := proto.ParseFormRequest(c, &req); err != nil { + rsp.ErrRsp(c, -3, "invalid parameters") + return + } + + password, err := utils.DecodeDecrypt(req.Password) + if err != nil || password == "" { + rsp.ErrRsp(c, -4, "invalid password") + return + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + rsp.ErrRsp(c, -5, "failed to hash password") + return + } + + if err = UpdateAccountPassword(username, string(hashed)); err != nil { + rsp.ErrRsp(c, -6, err.Error()) + return + } + + rsp.OkRsp(c) + log.Infof("password changed for user: %s", username) +} diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 3657ce4c..132b4efd 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -1,10 +1,7 @@ import { http } from '@/lib/http'; export function login(username: string, password: string) { - const data = { - username, - password - }; + const data = { username, password }; return http.post('/api/auth/login', data); } @@ -17,13 +14,31 @@ export function getAccount() { } export function changePassword(username: string, password: string) { - const data = { - username, - password - }; + const data = { username, password }; return http.post('/api/auth/password', data); } export function isPasswordUpdated() { return http.get('/api/auth/password'); } + +// Multi-user management +export function listUsers() { + return http.get('/api/auth/users'); +} + +export function createUser(username: string, password: string, role: string) { + return http.post('/api/auth/users', { username, password, role }); +} + +export function updateUser(username: string, data: { role?: string; enabled?: boolean }) { + return http.put(`/api/auth/users/${username}`, data); +} + +export function deleteUser(username: string) { + return http.delete(`/api/auth/users/${username}`); +} + +export function changeUserPassword(username: string, password: string) { + return http.post(`/api/auth/users/${username}/password`, { username, password }); +} diff --git a/web/src/hooks/useRole.ts b/web/src/hooks/useRole.ts new file mode 100644 index 00000000..1affd41d --- /dev/null +++ b/web/src/hooks/useRole.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; +import * as api from '@/api/auth.ts'; + +export type Role = 'admin' | 'operator' | 'viewer' | 'loading'; + +export function useRole() { + const [role, setRole] = useState('loading'); + + useEffect(() => { + api.getAccount().then((rsp: any) => { + if (rsp.code === 0 && rsp.data?.role) { + setRole(rsp.data.role as Role); + } else { + // Fallback: alles anzeigen wenn Rolle nicht ermittelt werden kann + setRole('admin'); + } + }).catch(() => { + setRole('admin'); + }); + }, []); + + // Während Ladezeit alles anzeigen + const loaded = role !== 'loading'; + + return { + role, + isAdmin: !loaded || role === 'admin', + isOperator: !loaded || role === 'admin' || role === 'operator', + isViewer: loaded && role === 'viewer', + }; +} diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index c989cc34..838343d5 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -78,7 +78,6 @@ const de = { ctrlaltdel: 'Ctrl+Alt+Del', dropdownEnglish: 'Englisch', dropdownGerman: 'Deutsch', - dropdownFrench: 'Französisch', dropdownRussian: 'Russisch' }, mouse: { @@ -334,6 +333,38 @@ const de = { updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.' } }, + users: { + title: 'Benutzerverwaltung', + addUser: 'Benutzer hinzufügen', + colUsername: 'Benutzername', + colRole: 'Rolle', + colEnabled: 'Aktiv', + colActions: 'Aktionen', + rolesTitle: 'Rollen-Übersicht', + roleAdmin: 'Vollzugriff + Benutzerverwaltung', + roleOperator: 'KVM nutzen: Stream, Tastatur, Maus, Power-Buttons', + roleViewer: 'Nur Stream anschauen', + changePassword: 'Passwort ändern', + newPassword: 'Neues Passwort', + confirmPassword: 'Passwort bestätigen', + pwdMismatch: 'Passwörter stimmen nicht überein', + pwdSuccess: 'Passwort erfolgreich geändert', + pwdFailed: 'Passwort ändern fehlgeschlagen', + password: 'Passwort', + delete: 'Löschen', + deleteConfirm: 'Benutzer wirklich löschen?', + createSuccess: 'Benutzer erstellt', + createFailed: 'Erstellen fehlgeschlagen', + deleteSuccess: 'Benutzer gelöscht', + deleteFailed: 'Löschen fehlgeschlagen', + updateSuccess: 'Aktualisiert', + updateFailed: 'Aktualisierung fehlgeschlagen', + loadFailed: 'Benutzer laden fehlgeschlagen', + usernameRequired: 'Benutzername eingeben', + passwordRequired: 'Passwort eingeben', + okBtn: 'OK', + cancelBtn: 'Abbrechen' + }, account: { title: 'Konto', webAccount: 'Web Konto Name', diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 5fea0ba9..f02209b1 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -91,7 +91,6 @@ const en = { clipboardReadError: 'Failed to read clipboard', dropdownEnglish: 'English', dropdownGerman: 'German', - dropdownFrench: 'French', dropdownRussian: 'Russian', shortcut: { title: 'Shortcuts', @@ -439,6 +438,38 @@ const en = { updateFailed: 'Update failed. Please retry.' } }, + users: { + title: 'User Management', + addUser: 'Add User', + colUsername: 'Username', + colRole: 'Role', + colEnabled: 'Enabled', + colActions: 'Actions', + rolesTitle: 'Role Overview', + roleAdmin: 'Full access + user management', + roleOperator: 'KVM control: stream, keyboard, mouse, power buttons', + roleViewer: 'View-only: watch stream', + changePassword: 'Change Password', + newPassword: 'New Password', + confirmPassword: 'Confirm Password', + pwdMismatch: 'Passwords do not match', + pwdSuccess: 'Password changed successfully', + pwdFailed: 'Failed to change password', + password: 'Password', + delete: 'Delete', + deleteConfirm: 'Are you sure you want to delete this user?', + createSuccess: 'User created', + createFailed: 'Failed to create user', + deleteSuccess: 'User deleted', + deleteFailed: 'Failed to delete user', + updateSuccess: 'Updated', + updateFailed: 'Update failed', + loadFailed: 'Failed to load users', + usernameRequired: 'Please enter a username', + passwordRequired: 'Please enter a password', + okBtn: 'OK', + cancelBtn: 'Cancel' + }, account: { title: 'Account', webAccount: 'Web Account Name', diff --git a/web/src/pages/auth/password/index.tsx b/web/src/pages/auth/password/index.tsx index a75457a5..117db4af 100644 --- a/web/src/pages/auth/password/index.tsx +++ b/web/src/pages/auth/password/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { LockOutlined, UserOutlined } from '@ant-design/icons'; +import { LockOutlined } from '@ant-design/icons'; import { Button, Card, Form, Input } from 'antd'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -25,20 +25,16 @@ export const Password = () => { setMsg(t('auth.differentPassword')); return; } - if (!validateString(values.username)) { - setMsg(t('auth.illegalUsername')); - return; - } if (!validateString(values.password)) { setMsg('auth.illegalPassword'); return; } - const username = values.username; + // initial password change is always for "admin" const password = encrypt(values.password); api - .changePassword(username, password) + .changePassword('admin', password) .then((rsp: any) => { if (rsp.code !== 0) { setMsg(t('auth.error')); @@ -74,13 +70,6 @@ export const Password = () => { initialValues={{ remember: true }} onFinish={changePassword} > - - } placeholder={t('auth.placeholderUsername')} /> - - { const nodeRef = useRef(null); - const menuDisabledItems = useAtomValue(menuDisabledItemsAtom); + const { isOperator, isAdmin } = useRole(); const { isInitialized, @@ -66,12 +67,10 @@ export const Menu = () => { onMouseLeave={() => handleHovered(false)} onBlur={() => handleHovered(false)} > - {/* Trigger area for auto-show when hidden */} {isMenuExpanded && (
)} - {/* Menubar */}
{ + {/* Screen immer sichtbar (nur Anzeige) */} - - + + {/* Tastatur & Maus: nur operator+ */} + {isOperator && } + {isOperator && } + + {/* Image/Download: alle */} {isEnabled('image') && } {isEnabled('download') && } - {isEnabled('script') &&