Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c14b21c
feat: multi-user authentication with role-based access control
Schattenwelt May 6, 2026
0a418b7
fix: remove username field from initial password change
Schattenwelt May 6, 2026
21527f3
chore: remove PR_DESCRIPTION.md (was meant for PR description only)
Schattenwelt May 6, 2026
81ba86c
merge: resolve conflict with upstream main
Schattenwelt May 7, 2026
f2b28e9
merge: resolve conflicts with upstream main (network tab + DNS settings)
Schattenwelt May 7, 2026
e339899
merge: resolve conflicts with upstream main
Schattenwelt May 8, 2026
fe7fa86
merge: resolve conflicts with upstream main
Schattenwelt May 8, 2026
5ec6c00
feat: add user management translations for all 22 languages
Schattenwelt May 9, 2026
702239a
feat: add user management translations for all 24 languages
Schattenwelt May 10, 2026
20e2941
merge: resolve i18n conflicts with upstream/main
Schattenwelt May 10, 2026
113d72c
Update README.md
Schattenwelt May 14, 2026
25a095f
Create release.yml
Schattenwelt May 14, 2026
c0df19e
Resolve merge conflict in Menu component
Schattenwelt May 21, 2026
e0bac87
Merge branch 'main' into multi-user-rbac
Schattenwelt May 21, 2026
34346c8
Update README.md
Schattenwelt May 21, 2026
7a9f0d7
Update CHANGELOG.md
Schattenwelt May 21, 2026
4c68e94
Sync repository source with v2.4.2 release binary
Schattenwelt May 21, 2026
2658587
Remove fork-specific deployment files for upstream PR
Schattenwelt Aug 12, 2026
7aebf22
Merge remote-tracking branch 'upstream/main' into multi-user-rbac
Schattenwelt Aug 12, 2026
6494558
Use roleColor map for role tags in user management
Schattenwelt Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 61 additions & 23 deletions server/middleware/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand All @@ -33,74 +65,80 @@ 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) {
c.JSON(http.StatusUnauthorized, "unauthorized")
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
})
if err != nil {
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
}
24 changes: 24 additions & 0 deletions server/proto/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type LoginRsp struct {

type GetAccountRsp struct {
Username string `json:"username"`
Role string `json:"role"`
}

type ChangePasswordReq struct {
Expand All @@ -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"`
}
25 changes: 20 additions & 5 deletions server/router/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
40 changes: 27 additions & 13 deletions server/router/hid.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,36 @@ func HIDLoopbackHTTPAllowedPaths() []string {

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 (paste, shortcuts, read/use keyboard)
opAPI := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(middleware.RoleAdmin, middleware.RoleOperator),
)

opAPI.POST("/hid/paste", service.Paste) // paste

opAPI.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
opAPI.POST("/hid/shortcut", service.AddShortcut) // add shortcut
opAPI.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut

opAPI.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key
opAPI.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key

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/mode", service.GetHidMode) // get hid mode

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/leds", service.GetKeyboardLedStatus) // get keyboard led status

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
api.GET("/hid/leds", service.GetKeyboardLedStatus)
// Admin only: HID hardware configuration
adminAPI := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(middleware.RoleAdmin),
)
adminAPI.POST("/hid/mode", service.SetHidMode) // set hid mode
adminAPI.POST("/hid/reset", service.ResetHid) // reset hid

// Internal loopback (for kvm_system / picoclaw): no JWT, only loopback token
localAPI := r.Group("/api/internal").Use(middleware.CheckLoopbackInternalToken())

localAPI.POST("/usb/recover", service.RecoverUSB)
}
}
34 changes: 21 additions & 13 deletions server/router/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,28 @@ import (
func networkRouter(r *gin.Engine) {
service := network.NewService()

// Unauthenticated endpoints (only meaningful in AP/setup mode)
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
r.POST("/api/network/wifi/verify", service.VerifyApLogin) // verify ap login

api := r.Group("/api").Use(middleware.CheckToken())

api.POST("/network/wol", service.WakeOnLAN) // wake on lan
api.GET("/network/wol/mac", service.GetMac) // get mac list
api.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
api.POST("/network/wol/mac/name", service.SetMacName) // set mac name

api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
api.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
api.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi

api.GET("/network/dns", service.GetDNS) // get DNS configuration
api.POST("/network/dns", service.SetDNS) // set DNS configuration
// Operator and admin: read network state, perform Wake-on-LAN
opAPI := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(middleware.RoleAdmin, middleware.RoleOperator),
)
opAPI.POST("/network/wol", service.WakeOnLAN) // wake on lan
opAPI.GET("/network/wol/mac", service.GetMac) // get mac list
opAPI.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
opAPI.GET("/network/dns", service.GetDNS) // get DNS configuration

// Admin only: network configuration
adminAPI := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(middleware.RoleAdmin),
)
adminAPI.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
adminAPI.POST("/network/wol/mac/name", service.SetMacName) // set mac name
adminAPI.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
adminAPI.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
adminAPI.POST("/network/dns", service.SetDNS) // set DNS configuration
}
Loading