From c390f11bcbc67936e03470a93272d18668f74644 Mon Sep 17 00:00:00 2001 From: aliaksei-by Date: Wed, 28 May 2025 12:06:18 +0300 Subject: [PATCH] multiuser support --- server/config/config.go | 2 + server/config/types.go | 11 +- server/main.go | 3 + server/middleware/jwt.go | 30 +++ server/proto/auth.go | 3 +- server/router/router.go | 1 + server/router/vm.go | 65 ++++-- server/service/auth/account.go | 201 +++++++++++++++--- server/service/auth/login.go | 20 +- server/service/auth/password.go | 33 ++- web/src/lib/localstorage.ts | 22 ++ web/src/pages/auth/login/index.tsx | 3 + web/src/pages/auth/login/tips.tsx | 1 + web/src/pages/auth/password/index.tsx | 15 +- web/src/pages/desktop/menu/index.tsx | 5 +- .../desktop/menu/settings/about/index.tsx | 4 +- .../desktop/menu/settings/account/index.tsx | 2 + .../menu/settings/appearance/menu-bar.tsx | 12 +- web/src/pages/desktop/menu/settings/index.tsx | 8 +- 19 files changed, 363 insertions(+), 78 deletions(-) diff --git a/server/config/config.go b/server/config/config.go index ab982b71..cea0d8f1 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -49,6 +49,8 @@ func initialize() { log.Println("NOTICE: Authentication is disabled! Please ensure your service is secure!") } + instance.Tokens = make(map[string]*User, 10) + log.Println("config loaded successfully") } diff --git a/server/config/types.go b/server/config/types.go index 2f8f0697..92a4f83a 100644 --- a/server/config/types.go +++ b/server/config/types.go @@ -1,5 +1,7 @@ package config +import "time" + type Config struct { Proto string `yaml:"proto"` Port Port `yaml:"port"` @@ -10,7 +12,8 @@ type Config struct { Stun string `yaml:"stun"` Turn Turn `yaml:"turn"` - Hardware Hardware `yaml:"-"` + Hardware Hardware `yaml:"-"` + Tokens map[string]*User `yaml:"-"` } type Logger struct { @@ -47,3 +50,9 @@ type Hardware struct { GPIOPowerLED string `yaml:"-"` GPIOHDDLed string `yaml:"-"` } + +type User struct { + Username string `yaml:"-"` + Group string `yaml:"-"` + ExpiresAt time.Time `yaml:"-"` +} diff --git a/server/main.go b/server/main.go index cb861fc0..5d3c9930 100644 --- a/server/main.go +++ b/server/main.go @@ -15,6 +15,7 @@ import ( "NanoKVM-Server/logger" "NanoKVM-Server/middleware" "NanoKVM-Server/router" + "NanoKVM-Server/service/auth" "NanoKVM-Server/service/vm/jiggler" "github.com/gin-gonic/gin" @@ -31,6 +32,8 @@ func main() { func initialize() { logger.Init() + auth.CheckAccountsFile() + // init screen parameters _ = common.GetScreen() diff --git a/server/middleware/jwt.go b/server/middleware/jwt.go index 86028815..9f1b8726 100644 --- a/server/middleware/jwt.go +++ b/server/middleware/jwt.go @@ -39,6 +39,36 @@ func CheckToken() gin.HandlerFunc { } } +func CheckAdminToken() gin.HandlerFunc { + return func(c *gin.Context) { + conf := config.GetInstance() + + if conf.Authentication == "disable" { + c.Next() + return + } + + cookie, err := c.Cookie("nano-kvm-token") + if err == nil { + _, err = ParseJWT(cookie) + if err == nil { + user, ok := config.GetInstance().Tokens[cookie] + if ok && user.Group != "admin" { + c.JSON(http.StatusForbidden, "unauthorized") + c.Abort() + return + } + + c.Next() + return + } + } + + c.JSON(http.StatusUnauthorized, "unauthorized") + c.Abort() + } +} + func GenerateJWT(username string) (string, error) { conf := config.GetInstance() diff --git a/server/proto/auth.go b/server/proto/auth.go index 6c4f28c3..7bc79e5e 100644 --- a/server/proto/auth.go +++ b/server/proto/auth.go @@ -6,7 +6,8 @@ type LoginReq struct { } type LoginRsp struct { - Token string `json:"token"` + Token string `json:"token"` + IsAdmin bool `json:"is_admin"` } type GetAccountRsp struct { diff --git a/server/router/router.go b/server/router/router.go index 8b6148ad..ea4d2e5d 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -32,6 +32,7 @@ func server(r *gin.Engine) { authRouter(r) applicationRouter(r) vmRouter(r) + vmAdminRouter(r) streamRouter(r) storageRouter(r) networkRouter(r) diff --git a/server/router/vm.go b/server/router/vm.go index f5a78a44..5cbfb8ff 100644 --- a/server/router/vm.go +++ b/server/router/vm.go @@ -19,24 +19,69 @@ func vmRouter(r *gin.Engine) { 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.POST("/vm/system/reboot", service.Reboot) // reboot system +} + +func vmAdminRouter(r *gin.Engine) { + service := vm.NewService() + api := r.Group("/api").Use(middleware.CheckAdminToken()) + 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 @@ -45,23 +90,15 @@ func vmRouter(r *gin.Engine) { 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.POST("/vm/system/reboot", service.Reboot) // reboot system } diff --git a/server/service/auth/account.go b/server/service/auth/account.go index 0305965e..740d5bda 100644 --- a/server/service/auth/account.go +++ b/server/service/auth/account.go @@ -1,29 +1,44 @@ package auth import ( + "NanoKVM-Server/config" "NanoKVM-Server/utils" "encoding/json" "errors" "os" "path/filepath" + "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" "golang.org/x/crypto/bcrypt" ) const AccountFile = "/etc/kvm/pwd" +type OldAccount struct { + Username string `json:"username"` + Password string `json:"password"` // should be named HashedPassword for clarity +} + type Account struct { Username string `json:"username"` - Password string `json:"password"` // should be named HashedPassword for clarity + Password string `json:"password"` // should be named HashedPassword for clarity + Group string `json:"group"` } +type Accounts []*Account + func GetAccount() (*Account, error) { + return nil, nil +} + +func GetAccounts() (Accounts, error) { if _, err := os.Stat(AccountFile); err != nil { if errors.Is(err, os.ErrNotExist) { - return getDefaultAccount(), nil + createDefaultAccounts() + } else { + return nil, err } - return nil, err } content, err := os.ReadFile(AccountFile) @@ -31,22 +46,54 @@ func GetAccount() (*Account, error) { return nil, err } - var account Account - if err = json.Unmarshal(content, &account); err != nil { + var accounts Accounts = []*Account{} + if err = json.Unmarshal(content, &accounts); err != nil { log.Errorf("unmarshal account failed: %s", err) return nil, err } - return &account, nil + return accounts, nil } func SetAccount(username string, hashedPassword string) error { - account, err := json.Marshal(&Account{ - Username: username, - Password: hashedPassword, - }) + if username == "admin" { + // check that is really admin + } + + return SetAccounts(username, hashedPassword) +} + +func SetAccounts(username string, hashedPassword string) error { + accounts, err := GetAccounts() + if err != nil { + return err + } + + isExists := false + for _, account := range accounts { + if username == account.Username { + account.Password = hashedPassword + isExists = true + break + } + } + + if !isExists { + accounts = append(accounts, &Account{ + Username: username, + Password: hashedPassword, + Group: func() string { + if username == "admin" { + return "admin" + } + return "web" + }(), + }) + } + + account, err := json.Marshal(accounts) if err != nil { - log.Errorf("failed to marshal account information to json: %s", err) + log.Errorf("failed to marshal accounts information to json: %s", err) return err } @@ -58,56 +105,140 @@ func SetAccount(username string, hashedPassword string) error { err = os.WriteFile(AccountFile, account, 0o644) if err != nil { - log.Errorf("write password failed: %s", err) + log.Errorf("write passwords failed: %s", err) return err } return nil } -func CompareAccount(username string, plainPassword string) bool { - account, err := GetAccount() +func CompareAccount(username string, plainPassword string) *Account { + accounts, err := GetAccounts() if err != nil { - return false + return nil } - if username != account.Username { - return false - } + for _, account := range accounts { + if account.Username != username { + continue + } - hashedPassword, err := utils.DecodeDecrypt(plainPassword) - if err != nil || hashedPassword == "" { - return false - } + hashedPassword, err := utils.DecodeDecrypt(plainPassword) + if err != nil || hashedPassword == "" { + return nil + } - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(hashedPassword)) - if err != nil { - // Compatible with old versions - accountHashedPassword, _ := utils.DecodeDecrypt(account.Password) - if accountHashedPassword == hashedPassword { - return true + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(hashedPassword)) + if err != nil { + // Compatible with old versions + accountHashedPassword, _ := utils.DecodeDecrypt(account.Password) + + if accountHashedPassword == hashedPassword { + return account + } + + return nil } - return false + return account } - return true + return nil } func DelAccount() error { - if err := os.Remove(AccountFile); err != nil { + log.Errorf("DelAccount") + /*if err := os.Remove(AccountFile); err != nil { log.Errorf("failed to delete password: %s", err) return err + }*/ + + return nil +} + +func CheckAccountsFile() error { + if _, err := os.Stat(AccountFile); err != nil { + if errors.Is(err, os.ErrNotExist) { + return createDefaultAccounts() + } else { + return err + } } + content, err := os.ReadFile(AccountFile) + if err != nil { + return err + } + + var oldAccount OldAccount + if err := json.Unmarshal(content, &oldAccount); err != nil { // sic! + return nil + } + + log.Errorf("remove old account file format: %s", err) + os.Remove(AccountFile) + + createDefaultAccounts() + return nil } -func getDefaultAccount() *Account { - hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) +func createDefaultAccounts() error { + var accounts Accounts = []*Account{} + + log.Infof("create default accounts file") - return &Account{ + // admin + hashedAdminPassword, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost) + accounts = append(accounts, &Account{ Username: "admin", - Password: string(hashedPassword), + Password: string(hashedAdminPassword), + Group: "admin", + }) + + //web + hashedWebPassword, _ := bcrypt.GenerateFromPassword([]byte("web"), bcrypt.DefaultCost) + accounts = append(accounts, &Account{ + Username: "web", + Password: string(hashedWebPassword), + Group: "web", + }) + + account, err := json.Marshal(accounts) + if err != nil { + log.Errorf("failed to marshal accounts information to json: %s", err) + return err } + + err = os.MkdirAll(filepath.Dir(AccountFile), 0o644) + if err != nil { + log.Errorf("create directory %s failed: %s", AccountFile, err) + return err + } + + err = os.WriteFile(AccountFile, account, 0o644) + if err != nil { + log.Errorf("write passwords failed: %s", err) + return err + } + + return nil +} + +func GetUserByToken(token string) *config.User { + user, ok := config.GetInstance().Tokens[token] + if ok { + return user + } + + return nil +} + +func GetUserByCookie(c *gin.Context) *config.User { + cookie, err := c.Cookie("nano-kvm-token") + if err == nil { + return GetUserByToken(cookie) + } + + return nil } diff --git a/server/service/auth/login.go b/server/service/auth/login.go index 8e9ba287..8dad42a5 100644 --- a/server/service/auth/login.go +++ b/server/service/auth/login.go @@ -4,6 +4,7 @@ import ( "NanoKVM-Server/config" "NanoKVM-Server/middleware" "NanoKVM-Server/proto" + "time" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" @@ -17,7 +18,8 @@ func (s *Service) Login(c *gin.Context) { conf := config.GetInstance() if conf.Authentication == "disable" { rsp.OkRspWithData(c, &proto.LoginRsp{ - Token: "disabled", + Token: "disabled", + IsAdmin: false, }) return } @@ -27,7 +29,8 @@ func (s *Service) Login(c *gin.Context) { return } - if ok := CompareAccount(req.Username, req.Password); !ok { + account := CompareAccount(req.Username, req.Password) + if account == nil { rsp.ErrRsp(c, -2, "invalid username or password") return } @@ -38,8 +41,17 @@ func (s *Service) Login(c *gin.Context) { return } + conf.Tokens[token] = &config.User{ + Username: account.Username, + Group: account.Group, + ExpiresAt: time.Now().Add(3 * time.Hour), + } + rsp.OkRspWithData(c, &proto.LoginRsp{ Token: token, + IsAdmin: func() bool { + return account.Group == "admin" + }(), }) log.Debugf("login success, username: %s", req.Username) @@ -59,8 +71,8 @@ func (s *Service) Logout(c *gin.Context) { func (s *Service) GetAccount(c *gin.Context) { var rsp proto.Response - account, err := GetAccount() - if err != nil { + account := GetUserByCookie(c) + if account == nil { rsp.ErrRsp(c, -1, "get account failed") return } diff --git a/server/service/auth/password.go b/server/service/auth/password.go index 22c60161..1cc92762 100644 --- a/server/service/auth/password.go +++ b/server/service/auth/password.go @@ -35,17 +35,28 @@ func (s *Service) ChangePassword(c *gin.Context) { return } - if err = SetAccount(req.Username, string(hashedPassword)); err != nil { - rsp.ErrRsp(c, -4, "failed to save password") - return - } - - // change root password - err = changeRootPassword(password) - if err != nil { - _ = DelAccount() - rsp.ErrRsp(c, -5, "failed to change password") - return + user := GetUserByCookie(c) + if user != nil { + // change user password + userName := user.Username + if user.Group == "admin" { + userName = req.Username + } + + if err = SetAccount(userName, string(hashedPassword)); err != nil { + rsp.ErrRsp(c, -4, "failed to save password") + return + } + + // change root password + if user.Username == "admin" { + err = changeRootPassword(password) + if err != nil { + _ = DelAccount() + rsp.ErrRsp(c, -5, "failed to change password") + return + } + } } rsp.OkRsp(c) diff --git a/web/src/lib/localstorage.ts b/web/src/lib/localstorage.ts index b6cfef00..b040faef 100644 --- a/web/src/lib/localstorage.ts +++ b/web/src/lib/localstorage.ts @@ -16,6 +16,8 @@ const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language'; const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password'; const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items'; const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm'; +const IS_ADMIN_KEY = 'nano-kvm-is-admin'; +const USERNAME_KEY = 'nano-kvm-username'; type ItemWithExpiry = { value: string; @@ -194,3 +196,23 @@ export function getPowerConfirm() { export function setPowerConfirm(enabled: boolean) { localStorage.setItem(POWER_CONFIRM_KEY, String(enabled)); } + +export function saveIsAdmin(isAdmin: boolean) { + const value = JSON.stringify(isAdmin); + localStorage.setItem(IS_ADMIN_KEY, value); +} + +export function loadIsAdmin() { + const value = localStorage.getItem(IS_ADMIN_KEY); + return value ? JSON.parse(value) : false; +} + +export function saveUsername(username: boolean) { + const value = JSON.stringify(username); + localStorage.setItem(USERNAME_KEY, value); +} + +export function loadUsername() { + const value = localStorage.getItem(USERNAME_KEY); + return value ? JSON.parse(value) : ""; +} diff --git a/web/src/pages/auth/login/index.tsx b/web/src/pages/auth/login/index.tsx index a94a881f..235a8c1b 100644 --- a/web/src/pages/auth/login/index.tsx +++ b/web/src/pages/auth/login/index.tsx @@ -10,6 +10,7 @@ import { encrypt } from '@/lib/encrypt.ts'; import { Head } from '@/components/head.tsx'; import { Tips } from './tips.tsx'; +import { saveIsAdmin, saveUsername } from '../../../lib/localstorage.js'; export const Login = (): ReactElement => { const navigate = useNavigate(); @@ -47,6 +48,8 @@ export const Login = (): ReactElement => { setMsg(''); setToken(rsp.data.token); + saveIsAdmin(rsp.data.is_admin); + saveUsername(username); navigate('/', { replace: true }); window.location.reload(); diff --git a/web/src/pages/auth/login/tips.tsx b/web/src/pages/auth/login/tips.tsx index 6a044913..4149310d 100644 --- a/web/src/pages/auth/login/tips.tsx +++ b/web/src/pages/auth/login/tips.tsx @@ -48,6 +48,7 @@ export const Tips = () => {
  • {t('auth.tips.reset3')} admin/admin + web/web
  • {t('auth.tips.reset4')} diff --git a/web/src/pages/auth/password/index.tsx b/web/src/pages/auth/password/index.tsx index a75457a5..99068382 100644 --- a/web/src/pages/auth/password/index.tsx +++ b/web/src/pages/auth/password/index.tsx @@ -8,10 +8,13 @@ import * as api from '@/api/auth.ts'; import { removeToken } from '@/lib/cookie.ts'; import { encrypt } from '@/lib/encrypt.ts'; import { Head } from '@/components/head.tsx'; +import { loadIsAdmin, loadUsername } from '../../../lib/localstorage'; export const Password = () => { const { t } = useTranslation(); const [msg, setMsg] = useState(''); + const isAdmin = loadIsAdmin(); + const Username = loadUsername(); const navigate = useNavigate(); useEffect(() => { @@ -34,7 +37,7 @@ export const Password = () => { return; } - const username = values.username; + const username = isAdmin ? values.username : Username; const password = encrypt(values.password); api @@ -74,12 +77,12 @@ export const Password = () => { initialValues={{ remember: true }} onFinish={changePassword} > - - } placeholder={t('auth.placeholderUsername')} /> - + } value={Username} placeholder={t('auth.placeholderUsername')} /> + } {
    {t('auth.tips.change1')}
    • {t('auth.tips.change2')}
    • -
    • {t('auth.tips.change3')}
    • + {isAdmin &&
    • {t('auth.tips.change3')}
    • }
    -
    {t('auth.tips.change4')}
    + {isAdmin &&
    {t('auth.tips.change4')}
    } diff --git a/web/src/pages/desktop/menu/index.tsx b/web/src/pages/desktop/menu/index.tsx index 97cc63c1..d2c61c7f 100644 --- a/web/src/pages/desktop/menu/index.tsx +++ b/web/src/pages/desktop/menu/index.tsx @@ -6,7 +6,7 @@ import { MenuIcon, XIcon } from 'lucide-react'; import Draggable from 'react-draggable'; import { useTranslation } from 'react-i18next'; -import { getMenuDisabledItems } from '@/lib/localstorage.ts'; +import { getMenuDisabledItems, loadIsAdmin } from '@/lib/localstorage.ts'; import { menuDisabledItemsAtom } from '@/jotai/settings.ts'; import { DownloadImage } from './download.tsx'; @@ -26,6 +26,7 @@ export const Menu = () => { const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom); const [isMenuOpen, setIsMenuOpen] = useState(true); + const isAdmin = loadIsAdmin(); const [bounds, setBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 }); const nodeRef = useRef(null); @@ -87,7 +88,7 @@ export const Menu = () => { {!menuDisabledItems.includes('image') && } {!menuDisabledItems.includes('download') && } {!menuDisabledItems.includes('script') &&