feat: add secure multi-user support (#876)

This commit is contained in:
肆月
2026-08-20 14:29:56 +08:00
committed by GitHub
parent d382f0621d
commit 71ab9127dd
59 changed files with 2715 additions and 523 deletions

View File

@@ -48,7 +48,7 @@ authentication: enable # Whether to enable identity verification fo
jwt:
secretKey: "" # The secret key used to sign and verify JWT Tokens. If left empty, a random key will be generated automatically on startup
refreshTokenDuration: 2678400 # The token refresh duration threshold in seconds before forcing a re-login. Default is `2678400` (~31 days)
revokeTokensOnLogout: true # Whether to invalidate all existing tokens upon logout by rotating the SecretKey. Default is `true`
revokeTokensOnLogout: true # Whether logout invalidates all sessions belonging to that user. Other users are never logged out. Setting this to false only clears the browser cookie and is not recommended. Default is `true`
security:
loginLockoutDuration: 0, # The duration (in seconds) to ban an IP from attempting to log in again after reaching the failure limit. If set to `0` or left empty, brute-force protection is disabled. Default is `0`
loginMaxFailures: 5, # The maximum number of continuous failed login attempts allowed per IP before triggering protection. Default is `5`
@@ -62,6 +62,28 @@ turn:
turnCred: example_cred # The credential/password required for authorization to the TURN server
```
## Web Users
NanoKVM uses two device-wide roles:
- `admin`: KVM access plus user, system, network, update, storage, terminal, script, MCP, and PicoClaw administration.
- `user`: KVM video, keyboard, mouse, paste, power/reset, and Wake-on-LAN access.
Administrators manage accounts from **Settings > Account**. Account data remains in
`/etc/kvm/pwd`; the server migrates the legacy single-account JSON format in place and writes
the multi-user format atomically with mode `0600`. Keeping the same path preserves the physical
BOOT-button password reset behavior.
Users must confirm their current password when changing it themselves; administrators can reset
non-owner users from the account manager. Only the device owner can change the device owner's
password, because that password is also synchronized to the Linux root account.
All authenticated sessions are backed by the current account state. Disabling, deleting,
changing the role or password of a user invalidates that user's HTTP and real-time connections
without affecting other users. Multiple users may watch and control the KVM concurrently; input
uses the existing cooperative HID coordinator, so simultaneous input can interleave.
Video mode, quality, resolution, and MJPEG frame-detection controls remain shared KVM
operations; when several users adjust them concurrently, the latest change applies device-wide.
## Compile & Deploy
Note: The manual steps below require a Linux x86-64 host with Go 1.25 or newer; they are not compatible with ARM, Windows or macOS. With Docker you can skip them entirely and use the containerized flow instead — the root [Makefile](../Makefile) (`make shell`) or the dev container (see "Development" in the root [README](../README.md)) — which works on any host OS; run `server/build.sh` inside the container for a release-equivalent build.

View File

@@ -46,7 +46,7 @@ authentication: enable # 是否开启 HTTP 接口与网页的身份
jwt:
secretKey: "" # 用于签发和验证 JWT Token 的密钥。如果不填,服务启动时将自动随机生成
refreshTokenDuration: 2678400 # 登录超时的刷新周期(单位:秒)。默认为 `2678400`约31天
revokeTokensOnLogout: true # 退出登录时是否废除所有现存的 Token。启用此项可以在注销时轮换 SecretKey强迫所有终端重新登录。默认为 `true`
revokeTokensOnLogout: true # 退出登录时是否废除该用户的全部会话;不会影响其他用户。设为 false 时仅清除浏览器 Cookie不推荐使用。默认为 `true`
security:
loginLockoutDuration: 0, # 达到失败上限后,禁止该 IP 再次尝试登录的持续时间(单位:秒)。如果设为 `0` 或不填,则代表不开启防暴力破解功能。默认为 `0`
loginMaxFailures: 5, # 允许触发保护前,单个 IP 连续登录失败的最大次数。默认为 `5`
@@ -60,6 +60,22 @@ turn:
turnCred: example_cred # TURN 服务器授权连接时使用的凭据/密码
```
## Web 多用户
NanoKVM 使用两种设备级角色:
- `admin`:除 KVM 操作外还可管理用户、系统、网络、更新、存储、终端、脚本、MCP 和 PicoClaw。
- `user`:可使用 KVM 视频、键盘、鼠标、粘贴、目标机电源/复位和网络唤醒。
管理员可在**设置 > 账户**中管理用户。账户数据继续保存在 `/etc/kvm/pwd`;服务会原地迁移旧版单账户
JSON并以 `0600` 权限原子写入多用户格式。沿用同一路径可保持长按 BOOT 键重置密码的现有行为。
用户自助修改密码时必须验证当前密码;管理员可在账户管理中重置非设备所有者的密码。由于设备所有者
密码还会同步至 Linux root 账户,因此只有设备所有者本人可以修改自己的密码。
所有登录会话都会校验当前账户状态。禁用、删除、修改角色或密码会立即撤销该用户的 HTTP 与实时连接,
且不会影响其他用户。多个用户可同时观看和协作控制 KVM输入沿用现有 HID 协调器,因此同时输入可能交错。
视频模式、画质、分辨率和 MJPEG 帧检测仍属于共享 KVM 操作;多人同时调整时,以最后一次设备级修改为准。
## 编译部署
**注意:请使用 Linux 操作系统x86-64和 Go 1.25 或更高版本。该工具链无法在 ARM、Windows 或 macOS 下使用。**

616
server/authn/store.go Normal file
View File

@@ -0,0 +1,616 @@
package authn
import (
"crypto/rand"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sync"
"NanoKVM-Server/utils"
"golang.org/x/crypto/bcrypt"
)
const (
AccountFile = "/etc/kvm/pwd"
currentFileVersion = 1
defaultUsername = "admin"
defaultPassword = "admin"
)
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("username already exists")
ErrLastAdmin = errors.New("at least one enabled admin is required")
ErrSelfModification = errors.New("administrators cannot disable or demote themselves")
ErrSelfDelete = errors.New("administrators cannot delete themselves")
ErrSystemAccount = errors.New("the device owner account must remain an enabled administrator")
usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$`)
)
type User struct {
Username string `json:"username"`
PasswordHash string `json:"password"`
Role Role `json:"role"`
Enabled bool `json:"enabled"`
TokenVersion uint64 `json:"tokenVersion"`
MustChangePassword bool `json:"mustChangePassword,omitempty"`
SystemAccount bool `json:"systemAccount,omitempty"`
}
type UserInfo struct {
Username string `json:"username"`
Role Role `json:"role"`
Enabled bool `json:"enabled"`
SystemAccount bool `json:"systemAccount,omitempty"`
}
type UserPatch struct {
Role *Role
Enabled *bool
}
type database struct {
Version int `json:"version"`
Users []User `json:"users"`
LegacyUsername string `json:"username,omitempty"`
LegacyPassword string `json:"password,omitempty"`
}
type legacyAccount struct {
Username string `json:"username"`
Password string `json:"password"`
}
type Store struct {
path string
mutex sync.RWMutex
}
var DefaultStore = NewStore(AccountFile)
func NewStore(path string) *Store {
return &Store{path: path}
}
func IsValidRole(role Role) bool {
return role == RoleAdmin || role == RoleUser
}
func ValidateUsername(username string) error {
if !usernamePattern.MatchString(username) {
return errors.New("username must be 1-32 characters and contain only letters, numbers, '.', '_' or '-'")
}
return nil
}
func ValidatePassword(password string) error {
length := len([]byte(password))
if length < 8 || length > 72 {
return errors.New("password must be between 8 and 72 bytes")
}
return nil
}
func (s *Store) List() ([]UserInfo, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
db, err := s.loadLocked(false)
if err != nil {
return nil, err
}
users := make([]UserInfo, 0, len(db.Users))
for _, user := range db.Users {
users = append(users, UserInfo{
Username: user.Username,
Role: user.Role,
Enabled: user.Enabled,
SystemAccount: user.SystemAccount,
})
}
return users, nil
}
func (s *Store) Get(username string) (*User, error) {
s.mutex.RLock()
defer s.mutex.RUnlock()
db, err := s.loadLocked(false)
if err != nil {
return nil, err
}
return findUser(db.Users, username)
}
func (s *Store) Authenticate(username, password string) (*User, bool, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return nil, false, err
}
user, err := findUser(db.Users, username)
if err != nil || !user.Enabled {
return nil, false, nil
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) == nil {
return user, true, nil
}
// Older releases stored a reversibly encrypted password. Upgrade it after
// the first successful login so the compatibility path is not permanent.
legacyPassword, decodeErr := utils.DecodeDecrypt(user.PasswordHash)
if decodeErr != nil || legacyPassword != password {
return nil, false, nil
}
hash, hashErr := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if hashErr != nil {
return nil, false, hashErr
}
for index := range db.Users {
if db.Users[index].Username == username {
db.Users[index].PasswordHash = string(hash)
if db.Users[index].TokenVersion == 0 {
db.Users[index].TokenVersion = 1
}
user = cloneUser(&db.Users[index])
break
}
}
if err = s.saveLocked(db); err != nil {
return nil, false, err
}
return user, true, nil
}
func (s *Store) ValidateToken(username string, tokenVersion uint64) (*User, error) {
user, err := s.Get(username)
if err != nil {
return nil, err
}
if !user.Enabled || user.TokenVersion != tokenVersion {
return nil, errors.New("session revoked")
}
return user, nil
}
func (s *Store) Create(username, password string, role Role) error {
if err := ValidateUsername(username); err != nil {
return err
}
if err := ValidatePassword(password); err != nil {
return err
}
if !IsValidRole(role) {
return errors.New("invalid role")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
tokenVersion, err := newTokenVersion()
if err != nil {
return err
}
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return err
}
if _, err = findUser(db.Users, username); err == nil {
return ErrUserExists
}
db.Users = append(db.Users, User{
Username: username,
PasswordHash: string(hash),
Role: role,
Enabled: true,
TokenVersion: tokenVersion,
})
return s.saveLocked(db)
}
func (s *Store) Update(actor, username string, patch UserPatch) (*User, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return nil, err
}
index := userIndex(db.Users, username)
if index < 0 {
return nil, ErrUserNotFound
}
user := db.Users[index]
if patch.Role != nil && !IsValidRole(*patch.Role) {
return nil, errors.New("invalid role")
}
if actor == username && user.Role == RoleAdmin &&
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
return nil, ErrSelfModification
}
if user.SystemAccount &&
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
return nil, ErrSystemAccount
}
changed := false
if patch.Role != nil && user.Role != *patch.Role {
user.Role = *patch.Role
changed = true
}
if patch.Enabled != nil && user.Enabled != *patch.Enabled {
user.Enabled = *patch.Enabled
changed = true
}
if !changed {
return &user, nil
}
db.Users[index] = user
if enabledAdminCount(db.Users) == 0 {
return nil, ErrLastAdmin
}
db.Users[index].TokenVersion++
if err = s.saveLocked(db); err != nil {
return nil, err
}
return cloneUser(&db.Users[index]), nil
}
func (s *Store) Delete(actor, username string) error {
if actor == username {
return ErrSelfDelete
}
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return err
}
index := userIndex(db.Users, username)
if index < 0 {
return ErrUserNotFound
}
if db.Users[index].SystemAccount {
return ErrSystemAccount
}
users := append([]User(nil), db.Users[:index]...)
users = append(users, db.Users[index+1:]...)
if enabledAdminCount(users) == 0 {
return ErrLastAdmin
}
db.Users = users
return s.saveLocked(db)
}
func (s *Store) SetPassword(username, password string) (*User, error) {
return s.SetPasswordAndRun(username, password, nil)
}
// SetPasswordAndRun commits the web password before running the optional
// system-side update. If that update fails, the account record is rolled back
// while the store lock is still held.
func (s *Store) SetPasswordAndRun(username, password string, afterCommit func() error) (*User, error) {
if err := ValidatePassword(password); err != nil {
return nil, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return nil, err
}
index := userIndex(db.Users, username)
if index < 0 {
return nil, ErrUserNotFound
}
previous := *db
previous.Users = append([]User(nil), db.Users...)
db.Users[index].PasswordHash = string(hash)
db.Users[index].MustChangePassword = false
db.Users[index].TokenVersion++
if err = s.saveLocked(db); err != nil {
return nil, err
}
if afterCommit != nil {
if err = afterCommit(); err != nil {
if rollbackErr := s.saveLocked(&previous); rollbackErr != nil {
return nil, fmt.Errorf("system password update failed: %v; account rollback failed: %w", err, rollbackErr)
}
return nil, err
}
}
return cloneUser(&db.Users[index]), nil
}
func (s *Store) Revoke(username string) (*User, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
db, err := s.loadLocked(true)
if err != nil {
return nil, err
}
index := userIndex(db.Users, username)
if index < 0 {
return nil, ErrUserNotFound
}
db.Users[index].TokenVersion++
if err = s.saveLocked(db); err != nil {
return nil, err
}
return cloneUser(&db.Users[index]), nil
}
func (s *Store) loadLocked(migrate bool) (*database, error) {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
db, defaultErr := defaultDatabase()
if defaultErr != nil {
return nil, defaultErr
}
if migrate {
if saveErr := s.saveLocked(db); saveErr != nil {
return nil, saveErr
}
}
return db, nil
}
if err != nil {
return nil, err
}
var db database
if err = json.Unmarshal(data, &db); err == nil && db.Version != 0 {
if err = validateDatabase(&db); err != nil {
return nil, err
}
return &db, nil
}
var legacy legacyAccount
if err = json.Unmarshal(data, &legacy); err != nil || legacy.Username == "" || legacy.Password == "" {
return nil, errors.New("invalid account file")
}
if err = ValidateUsername(legacy.Username); err != nil {
return nil, fmt.Errorf("invalid legacy username: %w", err)
}
tokenVersion, err := newTokenVersion()
if err != nil {
return nil, err
}
db = database{
Version: currentFileVersion,
Users: []User{{
Username: legacy.Username,
PasswordHash: legacy.Password,
Role: RoleAdmin,
Enabled: true,
TokenVersion: tokenVersion,
MustChangePassword: passwordMatches(legacy.Password, defaultPassword),
SystemAccount: true,
}},
}
if migrate {
if err = s.saveLocked(&db); err != nil {
return nil, err
}
}
return &db, nil
}
func (s *Store) saveLocked(db *database) error {
if err := validateDatabase(db); err != nil {
return err
}
syncLegacyAccount(db)
data, err := json.MarshalIndent(db, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
directory := filepath.Dir(s.path)
if err = os.MkdirAll(directory, 0o755); err != nil {
return err
}
temporary, err := os.CreateTemp(directory, ".pwd-*")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer func() { _ = os.Remove(temporaryPath) }()
if err = temporary.Chmod(0o600); err == nil {
_, err = temporary.Write(data)
}
if err == nil {
err = temporary.Sync()
}
closeErr := temporary.Close()
if err == nil {
err = closeErr
}
if err != nil {
return err
}
if err = os.Rename(temporaryPath, s.path); err != nil {
return err
}
dir, err := os.Open(directory)
if err != nil {
return err
}
defer func() { _ = dir.Close() }()
return dir.Sync()
}
func defaultDatabase() (*database, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(defaultPassword), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
tokenVersion, err := newTokenVersion()
if err != nil {
return nil, err
}
return &database{
Version: currentFileVersion,
Users: []User{{
Username: defaultUsername,
PasswordHash: string(hash),
Role: RoleAdmin,
Enabled: true,
TokenVersion: tokenVersion,
MustChangePassword: true,
SystemAccount: true,
}},
}, nil
}
func newTokenVersion() (uint64, error) {
var data [8]byte
for {
if _, err := rand.Read(data[:]); err != nil {
return 0, err
}
version := binary.LittleEndian.Uint64(data[:])
if version != 0 && version != ^uint64(0) {
return version, nil
}
}
}
func validateDatabase(db *database) error {
if db.Version != currentFileVersion {
return fmt.Errorf("unsupported account file version: %d", db.Version)
}
if len(db.Users) == 0 {
return errors.New("account file contains no users")
}
seen := make(map[string]struct{}, len(db.Users))
systemAccounts := 0
for index := range db.Users {
user := &db.Users[index]
if err := ValidateUsername(user.Username); err != nil {
return err
}
if user.PasswordHash == "" || !IsValidRole(user.Role) {
return errors.New("account file contains an invalid user")
}
if _, exists := seen[user.Username]; exists {
return errors.New("account file contains duplicate usernames")
}
seen[user.Username] = struct{}{}
if user.TokenVersion == 0 {
user.TokenVersion = 1
}
if user.SystemAccount {
systemAccounts++
if user.Role != RoleAdmin || !user.Enabled || systemAccounts > 1 {
return ErrSystemAccount
}
}
}
if enabledAdminCount(db.Users) == 0 {
return ErrLastAdmin
}
return nil
}
func findUser(users []User, username string) (*User, error) {
index := userIndex(users, username)
if index < 0 {
return nil, ErrUserNotFound
}
return cloneUser(&users[index]), nil
}
func userIndex(users []User, username string) int {
for index := range users {
if users[index].Username == username {
return index
}
}
return -1
}
func enabledAdminCount(users []User) int {
count := 0
for _, user := range users {
if user.Role == RoleAdmin && user.Enabled {
count++
}
}
return count
}
func cloneUser(user *User) *User {
if user == nil {
return nil
}
copy := *user
return &copy
}
func passwordMatches(hash, password string) bool {
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil {
return true
}
legacyPassword, err := utils.DecodeDecrypt(hash)
return err == nil && legacyPassword == password
}
// Keep a legacy top-level account mirror so downgrading to a single-user
// server still authenticates the original device owner instead of failing
// open or forcing an immediate physical reset.
func syncLegacyAccount(db *database) {
db.LegacyUsername = ""
db.LegacyPassword = ""
var fallback *User
for _, user := range db.Users {
if user.SystemAccount {
db.LegacyUsername = user.Username
db.LegacyPassword = user.PasswordHash
return
}
if fallback == nil && user.Role == RoleAdmin && user.Enabled {
copy := user
fallback = &copy
}
}
if fallback != nil {
db.LegacyUsername = fallback.Username
db.LegacyPassword = fallback.PasswordHash
}
}

212
server/authn/store_test.go Normal file
View File

@@ -0,0 +1,212 @@
package authn
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"golang.org/x/crypto/bcrypt"
)
func TestLegacyAccountMigratesInPlace(t *testing.T) {
path := filepath.Join(t.TempDir(), "pwd")
hash, err := bcrypt.GenerateFromPassword([]byte("legacy-password"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
legacy, _ := json.Marshal(legacyAccount{Username: "owner", Password: string(hash)})
if err = os.WriteFile(path, legacy, 0o644); err != nil {
t.Fatal(err)
}
store := NewStore(path)
user, ok, err := store.Authenticate("owner", "legacy-password")
if err != nil || !ok {
t.Fatalf("authenticate migrated account: ok=%v err=%v", ok, err)
}
if user.Role != RoleAdmin || !user.SystemAccount || user.TokenVersion == 0 {
t.Fatalf("unexpected migrated user: %+v", user)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var db database
if err = json.Unmarshal(data, &db); err != nil {
t.Fatalf("account file was not migrated: %v", err)
}
if db.Version != currentFileVersion || len(db.Users) != 1 {
t.Fatalf("unexpected database: %+v", db)
}
var downgrade legacyAccount
if err = json.Unmarshal(data, &downgrade); err != nil || downgrade.Username != "owner" || downgrade.Password == "" {
t.Fatalf("legacy downgrade mirror is invalid: %+v, %v", downgrade, err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("account mode = %o, want 600", info.Mode().Perm())
}
}
func TestCorruptAccountFileFailsClosed(t *testing.T) {
path := filepath.Join(t.TempDir(), "pwd")
if err := os.WriteFile(path, []byte("not json"), 0o600); err != nil {
t.Fatal(err)
}
_, ok, err := NewStore(path).Authenticate("admin", "admin")
if err == nil || ok {
t.Fatalf("corrupt file must fail closed: ok=%v err=%v", ok, err)
}
}
func TestUserLifecycleAndTokenRevocation(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "pwd"))
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err := store.Create("alice", "correct-horse", RoleUser); err != nil {
t.Fatal(err)
}
if _, ok, err := store.Authenticate("alice", "wrong-password"); err != nil || ok {
t.Fatalf("wrong password login: ok=%v err=%v", ok, err)
}
alice, err := store.Get("alice")
if err != nil {
t.Fatal(err)
}
if _, err = store.ValidateToken("alice", alice.TokenVersion); err != nil {
t.Fatal(err)
}
if _, err = store.SetPassword("alice", "new-password"); err != nil {
t.Fatal(err)
}
if _, err = store.ValidateToken("alice", alice.TokenVersion); err == nil {
t.Fatal("old token stayed valid after password change")
}
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || !ok {
t.Fatalf("new password login: ok=%v err=%v", ok, err)
}
disabled := false
if _, err = store.Update("admin", "alice", UserPatch{Enabled: &disabled}); err != nil {
t.Fatal(err)
}
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || ok {
t.Fatalf("disabled login: ok=%v err=%v", ok, err)
}
enabled := true
if _, err = store.Update("admin", "alice", UserPatch{Enabled: &enabled}); err != nil {
t.Fatal(err)
}
if err = store.Delete("admin", "alice"); err != nil {
t.Fatal(err)
}
if _, err = store.Get("alice"); !errors.Is(err, ErrUserNotFound) {
t.Fatalf("deleted user lookup error = %v", err)
}
}
func TestLastAdminAndSelfProtection(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "pwd"))
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
userRole := RoleUser
if _, err := store.Update("admin", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSelfModification) {
t.Fatalf("self demotion error = %v", err)
}
if err := store.Delete("admin", "admin"); !errors.Is(err, ErrSelfDelete) {
t.Fatalf("self delete error = %v", err)
}
if err := store.Create("second", "second-password", RoleAdmin); err != nil {
t.Fatal(err)
}
disabled := false
if _, err := store.Update("second", "admin", UserPatch{Enabled: &disabled}); !errors.Is(err, ErrSystemAccount) {
t.Fatalf("disable device owner error = %v", err)
}
if _, err := store.Update("second", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSystemAccount) {
t.Fatalf("demote device owner error = %v", err)
}
if err := store.Delete("second", "admin"); !errors.Is(err, ErrSystemAccount) {
t.Fatalf("delete device owner error = %v", err)
}
if err := store.Delete("admin", "second"); err != nil {
t.Fatal(err)
}
}
func TestRemovedAccountFileAndRecreatedUserInvalidateOldVersions(t *testing.T) {
path := filepath.Join(t.TempDir(), "pwd")
store := NewStore(path)
admin, ok, err := store.Authenticate("admin", "admin")
if err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err = os.Remove(path); err != nil {
t.Fatal(err)
}
if _, err = store.ValidateToken("admin", admin.TokenVersion); err == nil {
t.Fatal("old admin token survived account-file reset")
}
if _, ok, err = store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("login after reset: ok=%v err=%v", ok, err)
}
if err = store.Create("alice", "alice-password", RoleUser); err != nil {
t.Fatal(err)
}
alice, err := store.Get("alice")
if err != nil {
t.Fatal(err)
}
if err = store.Delete("admin", "alice"); err != nil {
t.Fatal(err)
}
if err = store.Create("alice", "alice-password", RoleUser); err != nil {
t.Fatal(err)
}
if _, err = store.ValidateToken("alice", alice.TokenVersion); err == nil {
t.Fatal("old token survived deletion and username reuse")
}
}
func TestConcurrentCreatesDoNotLoseUsers(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "pwd"))
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
const count = 5
var workers sync.WaitGroup
errorsCh := make(chan error, count)
for index := 0; index < count; index++ {
workers.Add(1)
go func(index int) {
defer workers.Done()
errorsCh <- store.Create(fmt.Sprintf("user%d", index), "valid-password", RoleUser)
}(index)
}
workers.Wait()
close(errorsCh)
for err := range errorsCh {
if err != nil {
t.Fatal(err)
}
}
users, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(users) != count+1 {
t.Fatalf("got %d users, want %d", len(users), count+1)
}
}

View File

@@ -7,13 +7,6 @@ import (
"time"
)
// RegenerateSecretKey regenerate secret key when logout
func RegenerateSecretKey() {
if instance.JWT.RevokeTokensOnLogout {
instance.JWT.SecretKey = generateRandomSecretKey()
}
}
// Generate random string for secret key.
func generateRandomSecretKey() string {
b := make([]byte, 64)

View File

@@ -1,68 +1,160 @@
package middleware
import (
"context"
"errors"
"net/http"
"time"
"NanoKVM-Server/authn"
"NanoKVM-Server/config"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/config"
)
const (
principalContextKey = "principal"
tokenContextKey = "token"
CookieName = "nano-kvm-token"
sessionRecheckDelay = 5 * time.Second
)
type Principal struct {
Username string
Role authn.Role
}
type Token struct {
Username string `json:"username"`
Username string `json:"username"`
TokenVersion uint64 `json:"tokenVersion"`
jwt.RegisteredClaims
}
func CheckToken() gin.HandlerFunc {
return func(c *gin.Context) {
if allowByToken(c) {
principal, token, ok := authenticate(c)
if !ok {
abortUnauthorized(c)
return
}
c.Set(principalContextKey, principal)
c.Set(tokenContextKey, token)
if token == nil || token.ExpiresAt == nil {
c.Next()
return
}
abortUnauthorized(c)
requestContext, cancel := context.WithCancel(c.Request.Context())
c.Request = c.Request.WithContext(requestContext)
unregister := activeSessions.register(principal.Username, cancel)
timer := time.AfterFunc(time.Until(token.ExpiresAt.Time), cancel)
go watchSessionState(requestContext, cancel, principal.Username, token.TokenVersion, sessionRecheckDelay)
defer func() {
timer.Stop()
unregister()
cancel()
}()
c.Next()
}
}
func watchSessionState(ctx context.Context, cancel context.CancelFunc, username string, tokenVersion uint64, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := authn.DefaultStore.ValidateToken(username, tokenVersion); err != nil {
cancel()
return
}
}
}
}
func RequireRole(roles ...authn.Role) gin.HandlerFunc {
allowed := make(map[authn.Role]struct{}, len(roles))
for _, role := range roles {
allowed[role] = struct{}{}
}
return func(c *gin.Context) {
principal, ok := CurrentPrincipal(c)
if !ok {
abortUnauthorized(c)
return
}
if _, ok = allowed[principal.Role]; !ok {
c.JSON(http.StatusForbidden, "forbidden")
c.Abort()
return
}
c.Next()
}
}
func CurrentPrincipal(c *gin.Context) (Principal, bool) {
value, exists := c.Get(principalContextKey)
if !exists {
return Principal{}, false
}
principal, ok := value.(Principal)
return principal, ok
}
func CheckLoopbackInternalToken() gin.HandlerFunc {
return func(c *gin.Context) {
if allowByLoopbackInternalToken(c.Request) {
c.Next()
return
}
abortUnauthorized(c)
}
}
func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc {
return func(c *gin.Context) {
if allowByToken(c) || allowByLoopbackInternalToken(c.Request) {
if allowByLoopbackInternalToken(c.Request) {
c.Next()
return
}
abortUnauthorized(c)
principal, token, ok := authenticate(c)
if !ok {
abortUnauthorized(c)
return
}
c.Set(principalContextKey, principal)
c.Set(tokenContextKey, token)
c.Next()
}
}
func allowByToken(c *gin.Context) bool {
func authenticate(c *gin.Context) (Principal, *Token, bool) {
conf := config.GetInstance()
if conf.Authentication == "disable" {
return true
return Principal{Username: "admin", Role: authn.RoleAdmin}, nil, true
}
cookie, err := c.Cookie("nano-kvm-token")
cookie, err := c.Cookie(CookieName)
if err != nil {
return false
return Principal{}, nil, false
}
_, err = ParseJWT(cookie)
return err == nil
token, err := ParseJWT(cookie)
if err != nil {
return Principal{}, nil, false
}
user, err := authn.DefaultStore.ValidateToken(token.Username, token.TokenVersion)
if err != nil {
log.Debugf("validate session for %q: %s", token.Username, err)
return Principal{}, nil, false
}
return Principal{Username: user.Username, Role: user.Role}, token, true
}
func abortUnauthorized(c *gin.Context) {
@@ -70,37 +162,44 @@ func abortUnauthorized(c *gin.Context) {
c.Abort()
}
func GenerateJWT(username string) (string, error) {
func GenerateJWT(username string, tokenVersion uint64) (string, error) {
conf := config.GetInstance()
now := time.Now()
expireDuration := time.Duration(conf.JWT.RefreshTokenDuration) * time.Second
claims := Token{
Username: username,
Username: username,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expireDuration)),
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(expireDuration)),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString([]byte(conf.JWT.SecretKey))
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.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
})
parsed, err := jwt.ParseWithClaims(
jwtToken,
&Token{},
func(token *jwt.Token) (interface{}, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return []byte(conf.JWT.SecretKey), nil
},
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithExpirationRequired(),
)
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
claims, ok := parsed.Claims.(*Token)
if !ok || !parsed.Valid || claims.Username == "" || claims.Subject != claims.Username || claims.TokenVersion == 0 {
return nil, errors.New("invalid token claims")
}
return claims, nil
}

View File

@@ -0,0 +1,230 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"NanoKVM-Server/authn"
"NanoKVM-Server/config"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
)
func TestCheckTokenUsesLiveRoleAndVersion(t *testing.T) {
gin.SetMode(gin.TestMode)
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
admin, ok, err := store.Authenticate("admin", "admin")
if err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err = store.Create("alice", "valid-password", authn.RoleUser); err != nil {
t.Fatal(err)
}
alice, err := store.Get("alice")
if err != nil {
t.Fatal(err)
}
restore := useTestAuthStore(t, store)
defer restore()
adminToken, err := GenerateJWT(admin.Username, admin.TokenVersion)
if err != nil {
t.Fatal(err)
}
userToken, err := GenerateJWT(alice.Username, alice.TokenVersion)
if err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/admin", CheckToken(), RequireRole(authn.RoleAdmin), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
if status := requestWithToken(router, "/admin", ""); status != http.StatusUnauthorized {
t.Fatalf("missing token status = %d", status)
}
if status := requestWithToken(router, "/admin", adminToken); status != http.StatusNoContent {
t.Fatalf("admin status = %d", status)
}
if status := requestWithToken(router, "/admin", userToken); status != http.StatusForbidden {
t.Fatalf("user status = %d", status)
}
if _, err = store.Revoke("admin"); err != nil {
t.Fatal(err)
}
if status := requestWithToken(router, "/admin", adminToken); status != http.StatusUnauthorized {
t.Fatalf("revoked status = %d", status)
}
}
func TestParseJWTRejectsOtherHMACMethods(t *testing.T) {
conf := config.GetInstance()
originalSecret := conf.JWT.SecretKey
conf.JWT.SecretKey = "test-secret"
defer func() { conf.JWT.SecretKey = originalSecret }()
now := time.Now()
claims := Token{
Username: "admin",
TokenVersion: 1,
RegisteredClaims: jwt.RegisteredClaims{
Subject: "admin",
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
},
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS384, claims).SignedString([]byte(conf.JWT.SecretKey))
if err != nil {
t.Fatal(err)
}
if _, err = ParseJWT(token); err == nil {
t.Fatal("HS384 token was accepted")
}
}
func TestParseJWTRejectsExpiredToken(t *testing.T) {
conf := config.GetInstance()
originalSecret := conf.JWT.SecretKey
conf.JWT.SecretKey = "test-secret"
defer func() { conf.JWT.SecretKey = originalSecret }()
claims := Token{
Username: "admin",
TokenVersion: 1,
RegisteredClaims: jwt.RegisteredClaims{
Subject: "admin",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(conf.JWT.SecretKey))
if err != nil {
t.Fatal(err)
}
if _, err = ParseJWT(token); err == nil {
t.Fatal("expired token was accepted")
}
}
func TestRevokeUserSessionsCancelsActiveRequests(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
unregister := activeSessions.register("alice", cancel)
defer unregister()
RevokeUserSessions("alice")
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("active session was not cancelled")
}
}
func TestAccountFileResetCancelsActiveSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "pwd")
store := authn.NewStore(path)
user, ok, err := store.Authenticate("admin", "admin")
if err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
restore := useTestAuthStore(t, store)
defer restore()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go watchSessionState(ctx, cancel, user.Username, user.TokenVersion, 10*time.Millisecond)
if err = os.Remove(path); err != nil {
t.Fatal(err)
}
select {
case <-ctx.Done():
case <-time.After(2 * time.Second):
t.Fatal("active session survived account-file reset")
}
}
func TestWatchWebSocketClosesRevokedConnection(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
connection, err := upgrader.Upgrade(writer, request, nil)
if err != nil {
return
}
defer connection.Close()
stop := WatchWebSocket(ctx, connection)
defer stop()
_, _, _ = connection.ReadMessage()
}))
defer server.Close()
wsURL := "ws" + server.URL[len("http"):]
connection, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatal(err)
}
defer connection.Close()
cancel()
_ = connection.SetReadDeadline(time.Now().Add(2 * time.Second))
_, _, err = connection.ReadMessage()
if !websocket.IsCloseError(err, SessionRevokedCloseCode) {
t.Fatalf("close error = %v, want code %d", err, SessionRevokedCloseCode)
}
}
func TestCheckWebSocketOrigin(t *testing.T) {
tests := []struct {
origin string
host string
want bool
}{
{"", "kvm.local", true},
{"http://kvm.local", "kvm.local", true},
{"http://KVM.local", "kvm.local", true},
{"http://kvm.local:8080", "kvm.local:8080", true},
{"http://kvm.local:8081", "kvm.local:8080", false},
{"http://evil.example", "kvm.local", false},
}
for _, test := range tests {
request := httptest.NewRequest(http.MethodGet, "http://"+test.host+"/api/ws", nil)
request.Host = test.host
if test.origin != "" {
request.Header.Set("Origin", test.origin)
}
if got := CheckWebSocketOrigin(request); got != test.want {
t.Fatalf("origin %q host %q = %v, want %v", test.origin, test.host, got, test.want)
}
}
}
func requestWithToken(handler http.Handler, path, token string) int {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.AddCookie(&http.Cookie{Name: CookieName, Value: token})
handler.ServeHTTP(recorder, request)
return recorder.Code
}
func useTestAuthStore(t *testing.T, store *authn.Store) func() {
t.Helper()
originalStore := authn.DefaultStore
conf := config.GetInstance()
originalAuthentication := conf.Authentication
originalSecret := conf.JWT.SecretKey
originalDuration := conf.JWT.RefreshTokenDuration
authn.DefaultStore = store
conf.Authentication = "enable"
conf.JWT.SecretKey = "test-secret"
conf.JWT.RefreshTokenDuration = 3600
return func() {
authn.DefaultStore = originalStore
conf.Authentication = originalAuthentication
conf.JWT.SecretKey = originalSecret
conf.JWT.RefreshTokenDuration = originalDuration
}
}

View File

@@ -0,0 +1,123 @@
package middleware
import (
"context"
"net"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
const SessionRevokedCloseCode = 4401
type sessionRegistry struct {
mutex sync.Mutex
nextID atomic.Uint64
byUserID map[string]map[uint64]context.CancelFunc
}
var activeSessions = &sessionRegistry{byUserID: make(map[string]map[uint64]context.CancelFunc)}
func (r *sessionRegistry) register(username string, cancel context.CancelFunc) func() {
id := r.nextID.Add(1)
r.mutex.Lock()
if r.byUserID[username] == nil {
r.byUserID[username] = make(map[uint64]context.CancelFunc)
}
r.byUserID[username][id] = cancel
r.mutex.Unlock()
return func() {
r.mutex.Lock()
delete(r.byUserID[username], id)
if len(r.byUserID[username]) == 0 {
delete(r.byUserID, username)
}
r.mutex.Unlock()
}
}
func RevokeUserSessions(username string) {
activeSessions.mutex.Lock()
sessions := activeSessions.byUserID[username]
delete(activeSessions.byUserID, username)
activeSessions.mutex.Unlock()
for _, cancel := range sessions {
cancel()
}
}
func WatchWebSocket(ctx context.Context, connection *websocket.Conn) func() {
stopped := make(chan struct{})
var stopOnce sync.Once
go func() {
select {
case <-ctx.Done():
_ = connection.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(SessionRevokedCloseCode, "session expired or revoked"),
time.Now().Add(2*time.Second),
)
_ = connection.Close()
case <-stopped:
}
}()
return func() { stopOnce.Do(func() { close(stopped) }) }
}
func CheckWebSocketOrigin(request *http.Request) bool {
origin := strings.TrimSpace(request.Header.Get("Origin"))
if origin == "" {
return true
}
parsed, err := url.Parse(origin)
if err != nil || parsed.Host == "" {
return false
}
requestScheme := "http"
if request.TLS != nil {
requestScheme = "https"
}
if forwarded := strings.TrimSpace(strings.Split(request.Header.Get("X-Forwarded-Proto"), ",")[0]); forwarded != "" {
requestScheme = strings.ToLower(forwarded)
}
return equalOrigin(parsed, request.Host, requestScheme)
}
func equalOrigin(origin *url.URL, requestHost, requestScheme string) bool {
originHost, originPort := splitHostPort(origin.Host)
host, port := splitHostPort(requestHost)
if !strings.EqualFold(originHost, host) {
return false
}
if originPort == "" {
originPort = defaultPort(origin.Scheme)
}
if port == "" {
port = defaultPort(requestScheme)
}
return originPort == port
}
func splitHostPort(value string) (string, string) {
host, port, err := net.SplitHostPort(value)
if err == nil {
return host, port
}
return strings.Trim(value, "[]"), ""
}
func defaultPort(scheme string) string {
switch strings.ToLower(scheme) {
case "https", "wss":
return "443"
default:
return "80"
}
}

View File

@@ -5,19 +5,38 @@ type LoginReq struct {
Password string `validate:"required"`
}
type LoginRsp struct {
Token string `json:"token"`
}
type GetAccountRsp struct {
Username string `json:"username"`
Role string `json:"role"`
}
type ChangePasswordReq struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
CurrentPassword string `json:"currentPassword"`
Password string `json:"password" validate:"required"`
}
type IsPasswordUpdatedRsp struct {
IsUpdated bool `json:"isUpdated"`
}
type UserInfo struct {
Username string `json:"username"`
Role string `json:"role"`
Enabled bool `json:"enabled"`
SystemAccount bool `json:"systemAccount,omitempty"`
}
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"`
}

View File

@@ -20,7 +20,7 @@ func ValidateRequest(req interface{}) error {
}
if env == "" || env == "debug" {
log.Debugf("request: %+v\n", req)
log.Debugf("request validated: %T", req)
}
return nil

View File

@@ -1,6 +1,7 @@
package router
import (
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/application"
@@ -9,7 +10,7 @@ import (
func applicationRouter(r *gin.Engine) {
service := application.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
api.GET("/application/version", service.GetVersion) // get application version
api.POST("/application/update", service.Update) // update application

View File

@@ -3,6 +3,7 @@ package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/auth"
)
@@ -18,4 +19,14 @@ func authRouter(r *gin.Engine) {
api.GET("/auth/account", service.GetAccount) // get account
api.POST("/auth/password", service.ChangePassword) // change password
api.POST("/auth/logout", service.Logout) // logout
admin := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
admin.GET("/auth/users", service.ListUsers)
admin.POST("/auth/users", service.CreateUser)
admin.PUT("/auth/users/:username", service.UpdateUser)
admin.DELETE("/auth/users/:username", service.DeleteUser)
admin.POST("/auth/users/:username/password", service.ChangeUserPassword)
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"time"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid"
@@ -19,7 +20,10 @@ type setAIControlModeRequest struct {
}
func controlRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
group := r.Group("/api/ai/control").Use(middleware.CheckToken())
group := r.Group("/api/ai/control").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
group.GET("/status", func(c *gin.Context) {
status, err := control.Status()
if err != nil {

View File

@@ -1,6 +1,7 @@
package router
import (
"NanoKVM-Server/authn"
"NanoKVM-Server/service/download"
"github.com/gin-gonic/gin"
@@ -9,7 +10,7 @@ import (
func downloadRouter(r *gin.Engine) {
service := download.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
api.POST("/download/image", service.DownloadImage) // download image
api.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download

View File

@@ -1,6 +1,7 @@
package router
import (
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/extensions/tailscale"
@@ -8,7 +9,10 @@ import (
)
func extensionsRouter(r *gin.Engine) {
api := r.Group("/api/extensions").Use(middleware.CheckToken())
api := r.Group("/api/extensions").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
ts := tailscale.NewService()

View File

@@ -3,6 +3,7 @@ package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/hid"
)
@@ -20,17 +21,21 @@ func hidRouter(r *gin.Engine) {
api.POST("/hid/paste", service.Paste) // 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
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key
api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key
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/mode", service.GetHidMode) // get hid mode
api.GET("/hid/leds", service.GetKeyboardLedStatus)
admin := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
admin.POST("/hid/mode", service.SetHidMode) // set hid mode
admin.POST("/hid/reset", service.ResetHid) // reset hid
admin.POST("/hid/shortcut", service.AddShortcut)
admin.DELETE("/hid/shortcut", service.DeleteShortcut)
admin.POST("/hid/shortcut/leader-key", service.SetLeaderKey)
localAPI.POST("/usb/recover", service.RecoverUSB)
}

View File

@@ -3,6 +3,7 @@ package router
import (
"net/http"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid"
@@ -23,7 +24,10 @@ func mcpRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *pic
picoclawService.PublishControlModeChangedFrom(status, "mcp_config")
},
)
management := r.Group("/api/mcp").Use(middleware.CheckToken())
management := r.Group("/api/mcp").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
management.GET("/config", service.GetConfig)
management.POST("/config", service.SetConfig)
management.POST("/key/regenerate", service.RegenerateAPIKey)

View File

@@ -3,6 +3,7 @@ package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/network"
)
@@ -15,15 +16,19 @@ func networkRouter(r *gin.Engine) {
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.POST("/network/wol", service.WakeOnLAN) // wake on lan
api.GET("/network/wol/mac", service.GetMac) // get mac list
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
admin := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
admin.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
admin.POST("/network/wol/mac/name", service.SetMacName) // set mac name
api.GET("/network/dns", service.GetDNS) // get DNS configuration
api.POST("/network/dns", service.SetDNS) // set DNS configuration
admin.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
admin.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
admin.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
admin.GET("/network/dns", service.GetDNS) // get DNS configuration
admin.POST("/network/dns", service.SetDNS) // set DNS configuration
}

View File

@@ -3,6 +3,7 @@ package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/picoclaw"
)
@@ -39,7 +40,10 @@ func PicoclawLoopbackHTTPAllowedPaths() []string {
}
func picoclawRouter(r *gin.Engine, service *picoclaw.Service) {
frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken())
frontendAPI := r.Group(picoclawBasePath).Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())
localAPI.GET(picoclawScreenshotPath, service.Screenshot)

View File

@@ -3,13 +3,14 @@ package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/storage"
)
func storageRouter(r *gin.Engine) {
service := storage.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
api.GET("/storage/image", service.GetImages) // get image list
api.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image

View File

@@ -3,6 +3,7 @@ package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/vm"
)
@@ -11,6 +12,10 @@ func vmRouter(r *gin.Engine) {
service := vm.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
admin := r.Group("/api").Use(
middleware.CheckToken(),
middleware.RequireRole(authn.RoleAdmin),
)
api.GET("/vm/info", service.GetInfo) // get device information
api.GET("/vm/hardware", service.GetHardware) // get hardware version
@@ -19,55 +24,55 @@ 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
admin.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
admin.GET("/vm/script", service.GetScripts) // get script
admin.POST("/vm/script/upload", service.UploadScript) // upload script
admin.POST("/vm/script/run", service.RunScript) // run script
admin.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
admin.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
admin.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
admin.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
admin.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
admin.GET("/vm/oled", service.GetOLED) // get OLED configuration
admin.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.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
admin.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
admin.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
admin.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
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
admin.GET("/vm/ssh", service.GetSSHState) // get SSH state
admin.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
admin.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
admin.GET("/vm/swap", service.GetSwap) // get swap file size
admin.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
admin.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
admin.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/hostname", service.GetHostname) // Get Hostname
admin.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/web-title", service.GetWebTitle) // Get web title
admin.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
admin.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
admin.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
admin.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
api.POST("/vm/tls", service.SetTls) // enable/disable TLS
admin.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
admin.GET("/vm/autostart", service.GetAutostart) // get autostart list
admin.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
admin.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
admin.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script
api.POST("/vm/system/reboot", service.Reboot) // reboot system
admin.POST("/vm/system/reboot", service.Reboot) // reboot system
}

View File

@@ -1,113 +0,0 @@
package auth
import (
"NanoKVM-Server/utils"
"encoding/json"
"errors"
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
)
const AccountFile = "/etc/kvm/pwd"
type Account struct {
Username string `json:"username"`
Password string `json:"password"` // should be named HashedPassword for clarity
}
func GetAccount() (*Account, error) {
if _, err := os.Stat(AccountFile); err != nil {
if errors.Is(err, os.ErrNotExist) {
return getDefaultAccount(), nil
}
return nil, err
}
content, err := os.ReadFile(AccountFile)
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
}
return &account, nil
}
func SetAccount(username string, hashedPassword string) error {
account, err := json.Marshal(&Account{
Username: username,
Password: hashedPassword,
})
if err != nil {
log.Errorf("failed to marshal account 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 password failed: %s", err)
return err
}
return nil
}
func CompareAccount(username string, plainPassword string) bool {
account, err := GetAccount()
if err != nil {
return false
}
if username != account.Username {
return false
}
hashedPassword, err := utils.DecodeDecrypt(plainPassword)
if err != nil || hashedPassword == "" {
return false
}
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
}
return false
}
return true
}
func DelAccount() error {
if err := os.Remove(AccountFile); err != nil {
log.Errorf("failed to delete password: %s", err)
return err
}
return nil
}
func getDefaultAccount() *Account {
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
return &Account{
Username: "admin",
Password: string(hashedPassword),
}
}

View File

@@ -1,11 +1,15 @@
package auth
import (
"net/http"
"strings"
"time"
"NanoKVM-Server/authn"
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -15,12 +19,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.OkRsp(c)
return
}
@@ -30,63 +31,98 @@ func (s *Service) Login(c *gin.Context) {
rsp.ErrRsp(c, code, msg)
return
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(3 * time.Second)
rsp.ErrRsp(c, -1, "invalid parameters")
return
}
if ok := CompareAccount(req.Username, req.Password); !ok {
password, err := utils.DecodeDecrypt(req.Password)
if err != nil || password == "" {
s.loginFailed(c, clientIP, &rsp)
return
}
user, ok, err := authn.DefaultStore.Authenticate(req.Username, password)
if err != nil {
log.Errorf("load account during login: %s", err)
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")
rsp.ErrRsp(c, -3, "authentication unavailable")
return
}
if !ok {
s.loginFailed(c, clientIP, &rsp)
return
}
ClearLoginAttempt(clientIP)
token, err := middleware.GenerateJWT(req.Username)
token, err := middleware.GenerateJWT(user.Username, user.TokenVersion)
if err != nil {
time.Sleep(1 * time.Second)
time.Sleep(time.Second)
rsp.ErrRsp(c, -3, "generate token failed")
return
}
setSessionCookie(c, token)
rsp.OkRsp(c)
log.Infof("user logged in: %s", user.Username)
}
rsp.OkRspWithData(c, &proto.LoginRsp{
Token: token,
})
log.Debugf("login success, username: %s", req.Username)
func (s *Service) loginFailed(c *gin.Context, clientIP string, rsp *proto.Response) {
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")
}
func (s *Service) Logout(c *gin.Context) {
conf := config.GetInstance()
if conf.JWT.RevokeTokensOnLogout {
config.RegenerateSecretKey()
}
var rsp proto.Response
principal, ok := middleware.CurrentPrincipal(c)
conf := config.GetInstance()
if ok && conf.Authentication != "disable" && conf.JWT.RevokeTokensOnLogout {
if _, err := authn.DefaultStore.Revoke(principal.Username); err != nil {
rsp.ErrRsp(c, -1, "failed to revoke session")
return
}
middleware.RevokeUserSessions(principal.Username)
log.Infof("user logged out: %s", principal.Username)
}
clearSessionCookie(c)
rsp.OkRsp(c)
}
func (s *Service) GetAccount(c *gin.Context) {
var rsp proto.Response
account, err := GetAccount()
if err != nil {
principal, ok := middleware.CurrentPrincipal(c)
if !ok {
rsp.ErrRsp(c, -1, "get account failed")
return
}
rsp.OkRspWithData(c, &proto.GetAccountRsp{
Username: account.Username,
Username: principal.Username,
Role: string(principal.Role),
})
log.Debugf("get account successful")
}
func setSessionCookie(c *gin.Context, token string) {
conf := config.GetInstance()
secure := conf.Proto == "https" || c.Request.TLS != nil ||
strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(
middleware.CookieName,
token,
int(conf.JWT.RefreshTokenDuration),
"/",
"",
secure,
true,
)
}
func clearSessionCookie(c *gin.Context) {
conf := config.GetInstance()
secure := conf.Proto == "https" || c.Request.TLS != nil ||
strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(middleware.CookieName, "", -1, "/", "", secure, true)
}

View File

@@ -1,124 +1,126 @@
package auth
import (
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"errors"
"io"
"os"
"os/exec"
"time"
"NanoKVM-Server/authn"
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
)
var systemPasswordUpdater = changeRootPassword
func (s *Service) ChangePassword(c *gin.Context) {
var req proto.ChangePasswordReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid parameters")
return
}
password, err := utils.DecodeDecrypt(req.Password)
if err != nil || password == "" {
rsp.ErrRsp(c, -2, "invalid password")
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
rsp.ErrRsp(c, -3, "failed to hash password")
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")
principal, ok := middleware.CurrentPrincipal(c)
if !ok {
rsp.ErrRsp(c, -2, "invalid session")
return
}
currentPassword, err := utils.DecodeDecrypt(req.CurrentPassword)
if err != nil || currentPassword == "" {
rsp.ErrRsp(c, -3, "current password is required")
return
}
if _, authenticated, authErr := authn.DefaultStore.Authenticate(principal.Username, currentPassword); authErr != nil {
rsp.ErrRsp(c, -4, "authentication unavailable")
return
} else if !authenticated {
rsp.ErrRsp(c, -3, "current password is incorrect")
return
}
if err := changeUserPassword(principal.Username, req.Password); err != nil {
rsp.ErrRsp(c, -5, err.Error())
return
}
middleware.RevokeUserSessions(principal.Username)
clearSessionCookie(c)
rsp.OkRsp(c)
log.Debugf("change password success, username: %s", req.Username)
log.Infof("password changed for user: %s", principal.Username)
}
func (s *Service) IsPasswordUpdated(c *gin.Context) {
var rsp proto.Response
if config.GetInstance().Authentication == "disable" {
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{IsUpdated: true})
return
}
principal, ok := middleware.CurrentPrincipal(c)
if !ok {
rsp.ErrRsp(c, -1, "invalid session")
return
}
user, err := authn.DefaultStore.Get(principal.Username)
if err != nil {
rsp.ErrRsp(c, -2, "failed to get password state")
return
}
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{IsUpdated: !user.MustChangePassword})
}
if _, err := os.Stat(AccountFile); err != nil {
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{
IsUpdated: false,
func changeUserPassword(username, encryptedPassword string) error {
password, err := utils.DecodeDecrypt(encryptedPassword)
if err != nil || password == "" {
return errInvalidPassword
}
if err = authn.ValidatePassword(password); err != nil {
return err
}
user, err := authn.DefaultStore.Get(username)
if err != nil {
return err
}
if user.SystemAccount && user.Role == authn.RoleAdmin && user.Enabled {
_, err = authn.DefaultStore.SetPasswordAndRun(username, password, func() error {
return systemPasswordUpdater(password)
})
return
return err
}
account, err := GetAccount()
if err != nil || account == nil {
rsp.ErrRsp(c, -1, "failed to get password")
return
}
err = 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),
})
_, err = authn.DefaultStore.SetPassword(username, password)
return err
}
func changeRootPassword(password string) error {
err := passwd(password)
if err != nil {
if err := passwd(password); err != nil {
log.Errorf("failed to change root password: %s", err)
return err
}
log.Debugf("change root password successful.")
log.Debug("change root password successful")
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()
}

View File

@@ -0,0 +1,307 @@
package auth
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"NanoKVM-Server/authn"
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
"github.com/mervick/aes-everywhere/go/aes256"
)
func TestLoginCookieAndUserAuthorizationLifecycle(t *testing.T) {
gin.SetMode(gin.TestMode)
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
service := NewService()
router := gin.New()
router.POST("/login", service.Login)
authenticated := router.Group("/").Use(middleware.CheckToken())
authenticated.GET("/account", service.GetAccount)
authenticated.POST("/logout", service.Logout)
admin := router.Group("/").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
admin.GET("/users", service.ListUsers)
admin.POST("/users", service.CreateUser)
admin.PUT("/users/:username", service.UpdateUser)
adminCookie := loginCookie(t, router, "admin", "admin")
if !adminCookie.HttpOnly || adminCookie.SameSite != http.SameSiteStrictMode || adminCookie.Path != "/" {
t.Fatalf("unsafe session cookie: %+v", adminCookie)
}
createBody := map[string]any{
"username": "alice",
"password": encryptForRequest("valid-password"),
"role": "user",
}
if code := requestJSON(router, http.MethodPost, "/users", createBody, adminCookie); code != http.StatusOK {
t.Fatalf("create status = %d", code)
}
userCookie := loginCookie(t, router, "alice", "valid-password")
if code := requestJSON(router, http.MethodGet, "/users", nil, userCookie); code != http.StatusForbidden {
t.Fatalf("user management status = %d, want 403", code)
}
disabled := false
if code := requestJSON(router, http.MethodPut, "/users/alice", map[string]any{"enabled": disabled}, adminCookie); code != http.StatusOK {
t.Fatalf("disable status = %d", code)
}
if code := requestJSON(router, http.MethodGet, "/account", nil, userCookie); code != http.StatusUnauthorized {
t.Fatalf("disabled session status = %d, want 401", code)
}
}
func TestLogoutDoesNotInvalidateAnotherUser(t *testing.T) {
gin.SetMode(gin.TestMode)
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err := store.Create("alice", "alice-password", authn.RoleUser); err != nil {
t.Fatal(err)
}
if err := store.Create("bob", "bob-password", authn.RoleUser); err != nil {
t.Fatal(err)
}
service := NewService()
router := gin.New()
router.POST("/login", service.Login)
authenticated := router.Group("/").Use(middleware.CheckToken())
authenticated.GET("/account", service.GetAccount)
authenticated.POST("/logout", service.Logout)
aliceCookie := loginCookie(t, router, "alice", "alice-password")
bobCookie := loginCookie(t, router, "bob", "bob-password")
if code := requestJSON(router, http.MethodPost, "/logout", nil, aliceCookie); code != http.StatusOK {
t.Fatalf("logout status = %d", code)
}
if code := requestJSON(router, http.MethodGet, "/account", nil, aliceCookie); code != http.StatusUnauthorized {
t.Fatalf("logged out session status = %d", code)
}
if code := requestJSON(router, http.MethodGet, "/account", nil, bobCookie); code != http.StatusOK {
t.Fatalf("other user's session status = %d", code)
}
}
func TestNormalUserPasswordNeverChangesSystemPassword(t *testing.T) {
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err := store.Create("alice", "alice-password", authn.RoleUser); err != nil {
t.Fatal(err)
}
originalUpdater := systemPasswordUpdater
updates := 0
systemPasswordUpdater = func(string) error {
updates++
return nil
}
defer func() { systemPasswordUpdater = originalUpdater }()
if err := changeUserPassword("alice", encryptForRequest("new-password")); err != nil {
t.Fatal(err)
}
if updates != 0 {
t.Fatalf("system password updated %d times for a normal user", updates)
}
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || !ok {
t.Fatalf("new web password login: ok=%v err=%v", ok, err)
}
}
func TestSelfPasswordChangeRequiresCurrentPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err := store.Create("alice", "alice-password", authn.RoleUser); err != nil {
t.Fatal(err)
}
service := NewService()
router := gin.New()
router.POST("/login", service.Login)
authenticated := router.Group("/").Use(middleware.CheckToken())
authenticated.POST("/password", service.ChangePassword)
cookie := loginCookie(t, router, "alice", "alice-password")
recorder := requestJSONRecorder(router, http.MethodPost, "/password", map[string]any{
"currentPassword": encryptForRequest("wrong-password"),
"password": encryptForRequest("new-password"),
}, cookie)
var response proto.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode wrong-current-password response: %v", err)
}
if response.Code != -3 || response.Msg != "current password is incorrect" {
t.Fatalf("wrong-current-password response = %+v", response)
}
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || ok {
t.Fatalf("password changed without current credential: ok=%v err=%v", ok, err)
}
requestJSON(router, http.MethodPost, "/password", map[string]any{
"currentPassword": encryptForRequest("alice-password"),
"password": encryptForRequest("new-password"),
}, cookie)
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || !ok {
t.Fatalf("valid password change failed: ok=%v err=%v", ok, err)
}
}
func TestAdministratorCannotResetDeviceOwnerPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
if err := store.Create("second-admin", "second-password", authn.RoleAdmin); err != nil {
t.Fatal(err)
}
originalUpdater := systemPasswordUpdater
updates := 0
systemPasswordUpdater = func(string) error {
updates++
return nil
}
defer func() { systemPasswordUpdater = originalUpdater }()
service := NewService()
router := gin.New()
router.POST("/login", service.Login)
admin := router.Group("/").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
admin.POST("/users/:username/password", service.ChangeUserPassword)
cookie := loginCookie(t, router, "second-admin", "second-password")
requestJSON(router, http.MethodPost, "/users/admin/password", map[string]any{
"password": encryptForRequest("new-password"),
}, cookie)
if updates != 0 {
t.Fatalf("administrator changed the device owner's system password %d times", updates)
}
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("device owner password changed: ok=%v err=%v", ok, err)
}
if _, ok, err := store.Authenticate("admin", "new-password"); err != nil || ok {
t.Fatalf("unauthorized password became active: ok=%v err=%v", ok, err)
}
}
func TestSystemPasswordFailureRollsBackWebPassword(t *testing.T) {
store := authn.NewStore(filepath.Join(t.TempDir(), "pwd"))
restore := useTestStore(store)
defer restore()
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("default login: ok=%v err=%v", ok, err)
}
originalUpdater := systemPasswordUpdater
systemPasswordUpdater = func(string) error { return errors.New("passwd failed") }
defer func() { systemPasswordUpdater = originalUpdater }()
if err := changeUserPassword("admin", encryptForRequest("new-password")); err == nil {
t.Fatal("system password failure was ignored")
}
if _, ok, err := store.Authenticate("admin", "admin"); err != nil || !ok {
t.Fatalf("old web password was not restored: ok=%v err=%v", ok, err)
}
if _, ok, err := store.Authenticate("admin", "new-password"); err != nil || ok {
t.Fatalf("failed password stayed active: ok=%v err=%v", ok, err)
}
}
func loginCookie(t *testing.T, handler http.Handler, username, password string) *http.Cookie {
t.Helper()
body := map[string]any{"username": username, "password": encryptForRequest(password)}
recorder := httptest.NewRecorder()
request := jsonRequest(http.MethodPost, "/login", body)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("login status = %d", recorder.Code)
}
if bytes.Contains(recorder.Body.Bytes(), []byte("token")) {
t.Fatalf("login exposed JWT in response: %s", recorder.Body.String())
}
for _, cookie := range recorder.Result().Cookies() {
if cookie.Name == middleware.CookieName {
return cookie
}
}
t.Fatal("login did not set session cookie")
return nil
}
func requestJSON(handler http.Handler, method, path string, body any, cookie *http.Cookie) int {
return requestJSONRecorder(handler, method, path, body, cookie).Code
}
func requestJSONRecorder(handler http.Handler, method, path string, body any, cookie *http.Cookie) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := jsonRequest(method, path, body)
if cookie != nil {
request.AddCookie(cookie)
}
handler.ServeHTTP(recorder, request)
return recorder
}
func jsonRequest(method, path string, body any) *http.Request {
var data []byte
if body != nil {
data, _ = json.Marshal(body)
}
request := httptest.NewRequest(method, path, bytes.NewReader(data))
request.Header.Set("Content-Type", "application/json")
return request
}
func encryptForRequest(password string) string {
return url.QueryEscape(aes256.Encrypt(password, utils.SecretKey))
}
func useTestStore(store *authn.Store) func() {
originalStore := authn.DefaultStore
conf := config.GetInstance()
originalAuthentication := conf.Authentication
originalSecret := conf.JWT.SecretKey
originalDuration := conf.JWT.RefreshTokenDuration
originalRevokeOnLogout := conf.JWT.RevokeTokensOnLogout
authn.DefaultStore = store
conf.Authentication = "enable"
conf.JWT.SecretKey = "test-secret"
conf.JWT.RefreshTokenDuration = 3600
conf.JWT.RevokeTokensOnLogout = true
return func() {
authn.DefaultStore = originalStore
conf.Authentication = originalAuthentication
conf.JWT.SecretKey = originalSecret
conf.JWT.RefreshTokenDuration = originalDuration
conf.JWT.RevokeTokensOnLogout = originalRevokeOnLogout
}
}

View File

@@ -0,0 +1,127 @@
package auth
import (
"errors"
"NanoKVM-Server/authn"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"NanoKVM-Server/utils"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
var errInvalidPassword = errors.New("invalid password")
func (s *Service) ListUsers(c *gin.Context) {
var rsp proto.Response
users, err := authn.DefaultStore.List()
if err != nil {
rsp.ErrRsp(c, -1, "failed to load users")
return
}
result := make([]proto.UserInfo, 0, len(users))
for _, user := range users {
result = append(result, proto.UserInfo{
Username: user.Username,
Role: string(user.Role),
Enabled: user.Enabled,
SystemAccount: user.SystemAccount,
})
}
rsp.OkRspWithData(c, &proto.ListUsersRsp{Users: result})
}
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
}
password, err := decodePassword(req.Password)
if err != nil {
rsp.ErrRsp(c, -2, err.Error())
return
}
if err = authn.DefaultStore.Create(req.Username, password, authn.Role(req.Role)); err != nil {
rsp.ErrRsp(c, -3, err.Error())
return
}
rsp.OkRsp(c)
log.Infof("user created: %s (%s)", req.Username, req.Role)
}
func (s *Service) UpdateUser(c *gin.Context) {
var req proto.UpdateUserReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil || (req.Role == nil && req.Enabled == nil) {
rsp.ErrRsp(c, -1, "invalid parameters")
return
}
principal, _ := middleware.CurrentPrincipal(c)
patch := authn.UserPatch{Enabled: req.Enabled}
if req.Role != nil {
role := authn.Role(*req.Role)
patch.Role = &role
}
username := c.Param("username")
if _, err := authn.DefaultStore.Update(principal.Username, username, patch); err != nil {
rsp.ErrRsp(c, -2, err.Error())
return
}
middleware.RevokeUserSessions(username)
rsp.OkRsp(c)
log.Infof("user updated: %s", username)
}
func (s *Service) DeleteUser(c *gin.Context) {
var rsp proto.Response
principal, _ := middleware.CurrentPrincipal(c)
username := c.Param("username")
if err := authn.DefaultStore.Delete(principal.Username, username); err != nil {
rsp.ErrRsp(c, -1, err.Error())
return
}
middleware.RevokeUserSessions(username)
rsp.OkRsp(c)
log.Infof("user deleted: %s", username)
}
func (s *Service) ChangeUserPassword(c *gin.Context) {
var req proto.ChangePasswordReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid parameters")
return
}
username := c.Param("username")
user, err := authn.DefaultStore.Get(username)
if err != nil {
rsp.ErrRsp(c, -2, err.Error())
return
}
if user.SystemAccount {
rsp.ErrRsp(c, -3, "the device owner must change its own password")
return
}
if err := changeUserPassword(username, req.Password); err != nil {
rsp.ErrRsp(c, -4, err.Error())
return
}
middleware.RevokeUserSessions(username)
rsp.OkRsp(c)
log.Infof("password reset for user: %s", username)
}
func decodePassword(encrypted string) (string, error) {
password, err := utils.DecodeDecrypt(encrypted)
if err != nil || password == "" {
return "", errInvalidPassword
}
if err = authn.ValidatePassword(password); err != nil {
return "", err
}
return password, nil
}

View File

@@ -7,6 +7,7 @@ import (
"sync"
"time"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/stream/mjpeg"
@@ -19,9 +20,7 @@ import (
var gatewayUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: middleware.CheckWebSocketOrigin,
}
type relayResult struct {
@@ -88,6 +87,8 @@ func (s *Service) ConnectGateway(c *gin.Context) {
GetSessionManager().Remove(sessionID)
return
}
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), downstream)
defer stopSessionWatcher()
GetSessionManager().AttachUpstream(sessionID, upstream)
GetSessionManager().AttachDownstream(sessionID, downstream)

View File

@@ -1,8 +1,8 @@
package direct
import (
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/stream"
"net/http"
"strconv"
"time"
@@ -17,9 +17,7 @@ var (
streamer = newStreamer()
upgrader = websocket.Upgrader{
WriteBufferSize: 256 * 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: middleware.CheckWebSocketOrigin,
}
)
@@ -29,6 +27,8 @@ func Connect(c *gin.Context) {
log.Errorf("failed to upgrade to websocket: %s", err)
return
}
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), ws)
defer stopSessionWatcher()
client := newClient(ws)
if flowWindow, err := strconv.Atoi(c.Query("flow")); err == nil && flowWindow > 0 {
client.queue.enableFlowControl(flowWindow)

View File

@@ -2,8 +2,8 @@ package webrtc
import (
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"encoding/json"
"net/http"
"sync"
"time"
@@ -17,9 +17,7 @@ import (
var (
upgrader = websocket.Upgrader{
WriteBufferSize: 256 * 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: middleware.CheckWebSocketOrigin,
}
globalManager *WebRTCManager
managerOnce sync.Once
@@ -39,6 +37,8 @@ func Connect(c *gin.Context) {
log.Errorf("failed to create h264 websocket: %s", err)
return
}
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), wsConn)
defer stopSessionWatcher()
defer func() {
_ = wsConn.Close()
log.Debugf("h264 websocket disconnected: %s", c.ClientIP())

View File

@@ -1,8 +1,8 @@
package vm
import (
"NanoKVM-Server/middleware"
"encoding/json"
"net/http"
"os"
"os/exec"
"time"
@@ -26,9 +26,7 @@ type WinSize struct {
var upgrader = websocket.Upgrader{
ReadBufferSize: maxMessageSize,
WriteBufferSize: maxMessageSize,
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: middleware.CheckWebSocketOrigin,
}
func (s *Service) Terminal(c *gin.Context) {
@@ -37,6 +35,8 @@ func (s *Service) Terminal(c *gin.Context) {
log.Errorf("failed to init websocket: %s", err)
return
}
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), ws)
defer stopSessionWatcher()
defer func() {
_ = ws.Close()
}()

View File

@@ -1,7 +1,7 @@
package ws
import (
"net/http"
"NanoKVM-Server/middleware"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
@@ -13,9 +13,7 @@ type Service struct{}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: middleware.CheckWebSocketOrigin,
}
func NewService() *Service {
@@ -28,6 +26,8 @@ func (s *Service) Connect(c *gin.Context) {
log.Errorf("create websocket failed: %s", err)
return
}
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), ws)
defer stopSessionWatcher()
log.Debug("websocket connected")

View File

@@ -27,7 +27,6 @@
"crypto-js": "^4.2.0",
"i18next": "^23.16.8",
"jotai": "^2.18.0",
"js-cookie": "^3.0.7",
"lucide-react": "^0.562.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -49,7 +48,6 @@
"@eslint/js": "^9.39.4",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@types/semver": "^7.7.1",

17
web/pnpm-lock.yaml generated
View File

@@ -41,9 +41,6 @@ importers:
jotai:
specifier: ^2.18.0
version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.29.7)(@types/react@18.3.28)(react@18.3.1)
js-cookie:
specifier: ^3.0.7
version: 3.0.7
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@18.3.1)
@@ -102,9 +99,6 @@ importers:
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
'@types/js-cookie':
specifier: ^3.0.6
version: 3.0.6
'@types/react':
specifier: ^18.3.28
version: 18.3.28
@@ -1026,9 +1020,6 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/js-cookie@3.0.6':
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -2025,10 +2016,6 @@ packages:
react:
optional: true
js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -4269,8 +4256,6 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/js-cookie@3.0.6': {}
'@types/json-schema@7.0.15': {}
'@types/mdast@4.0.4':
@@ -5476,8 +5461,6 @@ snapshots:
'@types/react': 18.3.28
react: 18.3.1
js-cookie@3.0.7: {}
js-tokens@4.0.0: {}
js-yaml@4.1.1:

View File

@@ -1,5 +1,17 @@
import { http } from '@/lib/http';
export type UserRole = 'admin' | 'user';
export type Account = {
username: string;
role: UserRole;
};
export type User = Account & {
enabled: boolean;
systemAccount?: boolean;
};
export function login(username: string, password: string) {
const data = {
username,
@@ -16,14 +28,34 @@ export function getAccount() {
return http.get('/api/auth/account');
}
export function changePassword(username: string, password: string) {
const data = {
username,
password
};
return http.post('/api/auth/password', data);
export function changePassword(currentPassword: string, password: string) {
return http.post('/api/auth/password', { currentPassword, password });
}
export function isPasswordUpdated() {
return http.get('/api/auth/password');
}
export function getUsers() {
return http.get('/api/auth/users');
}
export function createUser(username: string, password: string, role: UserRole) {
return http.post('/api/auth/users', { username, password, role });
}
export function updateUser(username: string, data: Partial<Pick<User, 'role' | 'enabled'>>) {
return http.request({
method: 'put',
url: `/api/auth/users/${encodeURIComponent(username)}`,
data
});
}
export function deleteUser(username: string) {
return http.delete(`/api/auth/users/${encodeURIComponent(username)}`);
}
export function resetUserPassword(username: string, password: string) {
return http.post(`/api/auth/users/${encodeURIComponent(username)}/password`, { password });
}

View File

@@ -1,14 +1,68 @@
import { ReactNode } from 'react';
import { ReactNode, useEffect, useState } from 'react';
import { AuthContext, useAuth } from '@/contexts/auth.ts';
import { Spin } from 'antd';
import { Navigate } from 'react-router-dom';
import { existToken } from '@/lib/cookie.ts';
import { Account, getAccount } from '@/api/auth.ts';
import { AUTH_EXPIRED_EVENT } from '@/lib/auth-events.ts';
export const ProtectedRoute = ({ children }: { children: ReactNode }) => {
const hasToken = existToken();
const [account, setAccount] = useState<Account | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(true);
if (!hasToken) {
useEffect(() => {
let active = true;
getAccount()
.then((rsp) => {
if (!active) return;
if (rsp.code !== 0 || !rsp.data?.username) {
setIsAuthenticated(false);
return;
}
setAccount({
username: rsp.data.username,
role: rsp.data.role === 'admin' ? 'admin' : 'user'
});
})
.catch(() => {
if (active) setIsAuthenticated(false);
})
.finally(() => {
if (active) setIsLoading(false);
});
const handleAuthExpired = () => {
setAccount(null);
setIsAuthenticated(false);
setIsLoading(false);
};
window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
return () => {
active = false;
window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
};
}, []);
if (isLoading) {
return (
<div className="flex h-screen w-screen items-center justify-center">
<Spin size="large" />
</div>
);
}
if (!isAuthenticated || !account) {
return <Navigate to={'/auth/login'} replace />;
}
return children;
return <AuthContext.Provider value={{ account }}>{children}</AuthContext.Provider>;
};
export const AdminRoute = ({ children }: { children: ReactNode }) => {
const { account } = useAuth();
return account.role === 'admin' ? children : <Navigate to="/" replace />;
};

View File

@@ -1,9 +1,9 @@
import { useEffect } from 'react';
import { useOptionalAuth } from '@/contexts/auth.ts';
import { useAtom } from 'jotai';
import { Helmet, HelmetData } from 'react-helmet-async';
import { getWebTitle } from '@/api/vm.ts';
import { existToken } from '@/lib/cookie.ts';
import { webTitleAtom } from '@/jotai/settings.ts';
type HeadProps = {
@@ -15,16 +15,17 @@ const helmetData = new HelmetData({});
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
const [webTitle, setWebTitle] = useAtom(webTitleAtom);
const auth = useOptionalAuth();
useEffect(() => {
if (!existToken()) return;
if (!auth) return;
getWebTitle().then((rsp) => {
if (rsp.data?.title) {
setWebTitle(rsp.data.title);
}
});
}, []);
}, [auth, setWebTitle]);
return (
<Helmet

21
web/src/contexts/auth.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createContext, useContext } from 'react';
import { Account } from '@/api/auth.ts';
export type AuthState = {
account: Account;
};
export const AuthContext = createContext<AuthState | null>(null);
export function useAuth() {
const auth = useContext(AuthContext);
if (!auth) {
throw new Error('useAuth must be used inside ProtectedRoute');
}
return auth;
}
export function useOptionalAuth() {
return useContext(AuthContext);
}

View File

@@ -11,14 +11,17 @@ const en = {
login: 'Login',
placeholderUsername: 'Username',
placeholderPassword: 'Password',
placeholderCurrentPassword: 'Current password',
placeholderPassword2: 'Please enter password again',
noEmptyUsername: 'Username required',
noEmptyPassword: 'Password required',
passwordLength: 'Password must be between 8 and 72 characters',
noAccount: 'Failed to get user information, please refresh web page or reset password',
invalidUser: 'Invalid username or password',
locked: 'Too many logins, please try again later',
globalLocked: 'System under protection, please try again later',
error: 'Unexpected error',
invalidCurrentPassword: 'Current password is incorrect',
changePassword: 'Change Password',
changePasswordDesc: 'For the security of your device, please change the password!',
differentPassword: 'Passwords do not match',
@@ -550,12 +553,33 @@ const en = {
account: {
title: 'Account',
webAccount: 'Web Account Name',
role: 'Role',
roles: {
admin: 'Administrator',
user: 'User'
},
password: 'Password',
updateBtn: 'Change',
logoutBtn: 'Logout',
logoutDesc: 'Are you sure you want to logout?',
okBtn: 'Yes',
cancelBtn: 'No'
cancelBtn: 'No',
users: {
title: 'Users',
create: 'Create User',
enabled: 'Enabled',
disabled: 'Disabled',
deviceOwner: 'Device owner',
resetPassword: 'Reset Password',
delete: 'Delete',
deleteConfirm: 'Delete this user and revoke all of their sessions?',
created: 'User created',
deleted: 'User deleted',
passwordUpdated: 'Password updated',
loadFailed: 'Failed to load users',
saveFailed: 'Failed to save user',
deleteFailed: 'Failed to delete user'
}
}
},
picoclaw: {

View File

@@ -11,14 +11,17 @@ const zh = {
login: '登录',
placeholderUsername: '请输入用户名',
placeholderPassword: '请输入密码',
placeholderCurrentPassword: '请输入当前密码',
placeholderPassword2: '请再次输入密码',
noEmptyUsername: '用户名不能为空',
noEmptyPassword: '密码不能为空',
passwordLength: '密码长度必须为 8 到 72 个字符',
noAccount: '获取用户信息失败,请刷新重试或重置密码',
invalidUser: '用户名或密码错误',
locked: '登录太频繁,请稍后再试',
globalLocked: '系统防爆破保护中,请稍后再试',
error: '未知错误',
invalidCurrentPassword: '当前密码错误',
changePassword: '修改密码',
changePasswordDesc: '为了您的设备安全,请修改密码!',
differentPassword: '两次密码不一致',
@@ -539,12 +542,33 @@ const zh = {
account: {
title: '帐号',
webAccount: '网页帐号',
role: '角色',
roles: {
admin: '管理员',
user: '普通用户'
},
password: '密码',
updateBtn: '修改',
logoutBtn: '退出',
logoutDesc: '确定要退出吗?',
okBtn: '确定',
cancelBtn: '取消'
cancelBtn: '取消',
users: {
title: '用户管理',
create: '创建用户',
enabled: '已启用',
disabled: '已禁用',
deviceOwner: '设备所有者',
resetPassword: '重置密码',
delete: '删除',
deleteConfirm: '删除此用户并撤销其全部会话?',
created: '用户已创建',
deleted: '用户已删除',
passwordUpdated: '密码已更新',
loadFailed: '加载用户失败',
saveFailed: '保存用户失败',
deleteFailed: '删除用户失败'
}
}
},
picoclaw: {

View File

@@ -0,0 +1,5 @@
export const AUTH_EXPIRED_EVENT = 'nano-kvm:auth-expired';
export function notifyAuthExpired() {
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT));
}

View File

@@ -1,23 +0,0 @@
import Cookies from 'js-cookie';
const COOKIE_TOKEN_KEY = 'nano-kvm-token';
export function existToken() {
const token = Cookies.get(COOKIE_TOKEN_KEY);
return !!token;
}
export function getToken() {
const token = Cookies.get(COOKIE_TOKEN_KEY);
if (!token) return null;
return token;
}
export function setToken(token: string) {
Cookies.set(COOKIE_TOKEN_KEY, token, { expires: 30 });
}
export function removeToken() {
Cookies.remove(COOKIE_TOKEN_KEY);
}

View File

@@ -1,6 +1,6 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { removeToken } from '@/lib/cookie.ts';
import { notifyAuthExpired } from '@/lib/auth-events.ts';
import { getBaseUrl } from '@/lib/service.ts';
type Response = {
@@ -42,8 +42,7 @@ class Http {
console.log(error);
const code = error.response?.status;
if (code === 401) {
removeToken();
window.location.reload();
notifyAuthExpired();
}
return Promise.reject(error);
}

View File

@@ -1,5 +1,6 @@
import { IMessageEvent, w3cwebsocket as W3cWebSocket } from 'websocket';
import { ICloseEvent, IMessageEvent, w3cwebsocket as W3cWebSocket } from 'websocket';
import { notifyAuthExpired } from '@/lib/auth-events.ts';
import { getBaseUrl } from '@/lib/service.ts';
type MessageHandler = (message: IMessageEvent) => void;
@@ -123,8 +124,16 @@ export class WsClient {
this.startHeartbeat();
}
private handleClose(): void {
private handleClose(event: ICloseEvent): void {
this.stopHeartbeat();
if (event.code === 4401) {
this.shouldReconnect = false;
this.cleanup();
notifyAuthExpired();
return;
}
this.scheduleReconnect();
}

View File

@@ -1,14 +1,28 @@
import { setupWorker } from 'msw/browser'
import { http, HttpResponse } from 'msw'
import { http, HttpResponse } from 'msw';
import { setupWorker } from 'msw/browser';
let isLoggedIn = false;
export const handlers = [
http.post('/api/auth/login', () => {
isLoggedIn = true;
return HttpResponse.json({
code: 0,
data: {
token: 'mocked_token',
},
})
code: 0,
data: {}
});
}),
]
export const worker = setupWorker(...handlers)
http.get('/api/auth/account', () => {
if (!isLoggedIn) {
return HttpResponse.json('unauthorized', { status: 401 });
}
return HttpResponse.json({
code: 0,
data: { username: 'admin', role: 'admin' }
});
}),
http.post('/api/auth/logout', () => {
isLoggedIn = false;
return HttpResponse.json({ code: 0 });
})
];
export const worker = setupWorker(...handlers);

View File

@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import * as api from '@/api/auth.ts';
import { existToken, setToken } from '@/lib/cookie.ts';
import { encrypt } from '@/lib/encrypt.ts';
import { Head } from '@/components/head.tsx';
@@ -19,10 +18,15 @@ export const Login = (): ReactElement => {
const [msg, setMsg] = useState('');
useEffect(() => {
if (existToken()) {
navigate('/', { replace: true });
}
}, []);
api
.getAccount()
.then((rsp) => {
if (rsp.code === 0 && rsp.data?.username) {
navigate('/', { replace: true });
}
})
.catch(() => {});
}, [navigate]);
useEffect(() => {
if (msg) {
@@ -51,10 +55,7 @@ export const Login = (): ReactElement => {
}
setMsg('');
setToken(rsp.data.token);
navigate('/', { replace: true });
window.location.reload();
})
.catch(() => {
setMsg(t('auth.error'));

View File

@@ -1,18 +1,24 @@
import { useEffect, useState } from 'react';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { useAuth } from '@/contexts/auth.ts';
import { LockOutlined } from '@ant-design/icons';
import { Button, Card, Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import * as api from '@/api/auth.ts';
import { removeToken } from '@/lib/cookie.ts';
import { notifyAuthExpired } from '@/lib/auth-events.ts';
import { encrypt } from '@/lib/encrypt.ts';
import { Head } from '@/components/head.tsx';
// This code is specific to POST /api/auth/password. The backend uses it when
// the authenticated user cannot verify their current password.
const invalidCurrentPasswordCode = -3;
export const Password = () => {
const { t } = useTranslation();
const [msg, setMsg] = useState('');
const navigate = useNavigate();
const { account } = useAuth();
useEffect(() => {
if (msg) {
@@ -25,27 +31,22 @@ 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;
const currentPassword = encrypt(values.currentPassword);
const password = encrypt(values.password);
api
.changePassword(username, password)
.changePassword(currentPassword, password)
.then((rsp: any) => {
if (rsp.code !== 0) {
setMsg(t('auth.error'));
setMsg(
rsp.code === invalidCurrentPasswordCode
? t('auth.invalidCurrentPassword')
: t('auth.error')
);
return;
}
removeToken();
notifyAuthExpired();
navigate('/auth/login', { replace: true });
})
.catch(() => {
@@ -53,11 +54,6 @@ export const Password = () => {
});
}
function validateString(str: string) {
const regex = /['"\\/]/;
return !regex.test(str);
}
function cancel() {
window.location.replace('/');
}
@@ -75,30 +71,43 @@ export const Password = () => {
onFinish={changePassword}
>
<Form.Item
name="username"
rules={[{ required: true, message: t('auth.noEmptyUsername'), min: 1 }]}
>
<Input prefix={<UserOutlined />} placeholder={t('auth.placeholderUsername')} />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
name="currentPassword"
rules={[{ required: true, message: t('auth.noEmptyPassword') }]}
>
<Input
prefix={<LockOutlined />}
type="password"
autoComplete="current-password"
placeholder={t('auth.placeholderCurrentPassword')}
/>
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: t('auth.noEmptyPassword') },
{ min: 8, max: 72, message: t('auth.passwordLength') }
]}
>
<Input
prefix={<LockOutlined />}
type="password"
autoComplete="new-password"
placeholder={t('auth.placeholderPassword')}
/>
</Form.Item>
<Form.Item
name="password2"
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
rules={[
{ required: true, message: t('auth.noEmptyPassword') },
{ min: 8, max: 72, message: t('auth.passwordLength') }
]}
>
<Input
prefix={<LockOutlined />}
type="password"
autoComplete="new-password"
placeholder={t('auth.placeholderPassword2')}
/>
</Form.Item>
@@ -116,16 +125,18 @@ export const Password = () => {
</Form.Item>
</Form>
<Card>
<div className="flex w-[450px] flex-col">
<div>{t('auth.tips.change1')}</div>
<ul className="list-outside list-decimal">
<li>{t('auth.tips.change2')}</li>
<li>{t('auth.tips.change3')}</li>
</ul>
<div className="text-red-500">{t('auth.tips.change4')}</div>
</div>
</Card>
{account.role === 'admin' && (
<Card>
<div className="flex w-[450px] flex-col">
<div>{t('auth.tips.change1')}</div>
<ul className="list-outside list-decimal">
<li>{t('auth.tips.change2')}</li>
<li>{t('auth.tips.change3')}</li>
</ul>
<div className="text-red-500">{t('auth.tips.change4')}</div>
</div>
</Card>
)}
</div>
</>
);

View File

@@ -1,4 +1,5 @@
import { useRef } from 'react';
import { useAuth } from '@/contexts/auth.ts';
import { Divider } from 'antd';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
@@ -26,6 +27,8 @@ import { Wol } from './wol';
export const Menu = () => {
const nodeRef = useRef<HTMLDivElement | null>(null);
const { account } = useAuth();
const isAdmin = account.role === 'admin';
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
const isKeyboardLedStatusVisible = useAtomValue(keyboardLedStatusVisibleAtom);
@@ -104,17 +107,18 @@ export const Menu = () => {
<Mouse />
<Divider type="vertical" />
{isEnabled('image') && <Image />}
{isEnabled('download') && <DownloadImage />}
{isEnabled('terminal') && <Terminal />}
{isEnabled('script') && <Script />}
{isAdmin && isEnabled('image') && <Image />}
{isAdmin && isEnabled('download') && <DownloadImage />}
{isAdmin && isEnabled('terminal') && <Terminal />}
{isAdmin && isEnabled('script') && <Script />}
{isEnabled('wol') && <Wol />}
{['image', 'download', 'script', 'terminal', 'wol'].some(isEnabled) && (
{(isEnabled('wol') ||
(isAdmin && ['image', 'download', 'script', 'terminal'].some(isEnabled))) && (
<Divider type="vertical" />
)}
{isEnabled('picoclaw') && (
{isAdmin && isEnabled('picoclaw') && (
<>
<Picoclaw />
<Divider type="vertical" />

View File

@@ -1,3 +1,4 @@
import { useAuth } from '@/contexts/auth.ts';
import { KeyboardIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -10,13 +11,14 @@ import { VirtualKeyboard } from './virtual-keyboard.tsx';
export const Keyboard = () => {
const { t } = useTranslation();
const { account } = useAuth();
const content = (
<div className="flex flex-col space-y-1">
<Paste />
<VirtualKeyboard />
<Shortcuts />
<LeaderKey />
{account.role === 'admin' && <LeaderKey />}
</div>
);

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@/contexts/auth.ts';
import { Divider, Popover } from 'antd';
import { CommandIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -12,6 +13,7 @@ import type { Shortcut as ShortcutInterface } from './types.ts';
export const Shortcuts = () => {
const { t } = useTranslation();
const { account } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
@@ -112,12 +114,14 @@ export const Shortcuts = () => {
<Divider style={{ margin: '5px 0 5px 0' }} />
<Recorder
shortcuts={customShortcuts}
addShortcut={addShortcut}
delShortcut={delShortcut}
setIsRecording={setIsRecording}
/>
{account.role === 'admin' && (
<Recorder
shortcuts={customShortcuts}
addShortcut={addShortcut}
delShortcut={delShortcut}
setIsRecording={setIsRecording}
/>
)}
</ScrollArea>
);

View File

@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
export const Hostname = () => {
export const Hostname = ({ editable = false }: { editable?: boolean }) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
@@ -83,12 +83,14 @@ export const Hostname = () => {
) : (
<div className="flex items-center space-x-2">
<span>{hostname}</span>
<div
className="size-[16px] cursor-pointer text-neutral-500 hover:text-blue-500"
onClick={showInput}
>
<ClipboardPenIcon size={16} />
</div>
{editable && (
<div
className="size-[16px] cursor-pointer text-neutral-500 hover:text-blue-500"
onClick={showInput}
>
<ClipboardPenIcon size={16} />
</div>
)}
</div>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@/contexts/auth.ts';
import { Tooltip } from 'antd';
import { CircleHelpIcon, EthernetPortIcon, WifiIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -24,6 +25,7 @@ type Info = {
export const Information = () => {
const { t } = useTranslation();
const { account } = useAuth();
const [information, setInformation] = useState<Info>();
@@ -106,7 +108,7 @@ export const Information = () => {
<span>{information ? information.application : '-'}</span>
</div>
<Hostname />
<Hostname editable={account.role === 'admin'} />
</div>
</>
);

View File

@@ -1,25 +1,15 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@/contexts/auth.ts';
import { Button, Divider } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import * as api from '@/api/auth.ts';
import { Logout } from './logout.tsx';
import { Users } from './users.tsx';
export const Account = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [username, setUsername] = useState('');
useEffect(() => {
api.getAccount().then((rsp) => {
if (rsp.code === 0) {
setUsername(rsp.data.username);
}
});
}, []);
const { account } = useAuth();
function changePassword() {
navigate('/auth/password');
@@ -33,7 +23,12 @@ export const Account = () => {
<div className="flex flex-col space-y-8">
<div className="flex items-center justify-between">
<span>{t('settings.account.webAccount')}</span>
<span>{username ? username : '-'}</span>
<span>{account.username}</span>
</div>
<div className="flex items-center justify-between">
<span>{t('settings.account.role')}</span>
<span>{t(`settings.account.roles.${account.role}`)}</span>
</div>
<div className="flex items-center justify-between">
@@ -46,6 +41,13 @@ export const Account = () => {
<Divider className="opacity-50" />
{account.role === 'admin' && (
<>
<Users />
<Divider className="opacity-50" />
</>
)}
<Logout />
</>
);

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import * as api from '@/api/auth.ts';
import { removeToken } from '@/lib/cookie.ts';
import { notifyAuthExpired } from '@/lib/auth-events.ts';
export const Logout = () => {
const { t } = useTranslation();
@@ -17,7 +17,7 @@ export const Logout = () => {
return;
}
removeToken();
notifyAuthExpired();
navigate('/auth/login');
});
}

View File

@@ -0,0 +1,256 @@
import { useCallback, useEffect, useState } from 'react';
import { useAuth } from '@/contexts/auth.ts';
import { Button, Form, Input, message, Modal, Popconfirm, Select, Switch } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/auth.ts';
import { User, UserRole } from '@/api/auth.ts';
import { encrypt } from '@/lib/encrypt.ts';
type CreateValues = {
username: string;
password: string;
role: UserRole;
};
type PasswordValues = {
password: string;
};
export const Users = () => {
const { t } = useTranslation();
const { account } = useAuth();
const [messageApi, contextHolder] = message.useMessage();
const [createForm] = Form.useForm<CreateValues>();
const [passwordForm] = Form.useForm<PasswordValues>();
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [passwordUser, setPasswordUser] = useState<string | null>(null);
const loadUsers = useCallback(async () => {
setIsLoading(true);
try {
const rsp = await api.getUsers();
if (rsp.code !== 0) {
throw new Error(rsp.msg);
}
const data = Array.isArray(rsp.data) ? rsp.data : rsp.data?.users;
setUsers(
(Array.isArray(data) ? data : []).map((user: User) => ({
username: user.username,
role: user.role === 'admin' ? 'admin' : 'user',
enabled: user.enabled !== false,
systemAccount: user.systemAccount === true
}))
);
} catch {
messageApi.error(t('settings.account.users.loadFailed'));
} finally {
setIsLoading(false);
}
}, [messageApi, t]);
useEffect(() => {
loadUsers();
}, [loadUsers]);
async function createUser(values: CreateValues) {
setIsLoading(true);
try {
const rsp = await api.createUser(values.username, encrypt(values.password), values.role);
if (rsp.code !== 0) throw new Error(rsp.msg);
setIsCreateOpen(false);
createForm.resetFields();
messageApi.success(t('settings.account.users.created'));
await loadUsers();
} catch {
messageApi.error(t('settings.account.users.saveFailed'));
setIsLoading(false);
}
}
async function updateUser(user: User, changes: Partial<Pick<User, 'role' | 'enabled'>>) {
setIsLoading(true);
try {
const rsp = await api.updateUser(user.username, changes);
if (rsp.code !== 0) throw new Error(rsp.msg);
await loadUsers();
} catch {
messageApi.error(t('settings.account.users.saveFailed'));
setIsLoading(false);
}
}
async function deleteUser(username: string) {
setIsLoading(true);
try {
const rsp = await api.deleteUser(username);
if (rsp.code !== 0) throw new Error(rsp.msg);
messageApi.success(t('settings.account.users.deleted'));
await loadUsers();
} catch {
messageApi.error(t('settings.account.users.deleteFailed'));
setIsLoading(false);
}
}
async function resetPassword(values: PasswordValues) {
if (!passwordUser) return;
setIsLoading(true);
try {
const rsp = await api.resetUserPassword(passwordUser, encrypt(values.password));
if (rsp.code !== 0) throw new Error(rsp.msg);
setPasswordUser(null);
passwordForm.resetFields();
messageApi.success(t('settings.account.users.passwordUpdated'));
} catch {
messageApi.error(t('settings.account.users.saveFailed'));
} finally {
setIsLoading(false);
}
}
return (
<>
{contextHolder}
<div className="mb-4 flex items-center justify-between">
<div className="text-base">{t('settings.account.users.title')}</div>
<Button type="primary" onClick={() => setIsCreateOpen(true)}>
{t('settings.account.users.create')}
</Button>
</div>
<div className="flex flex-col space-y-2 opacity-100">
{users.map((user) => {
const isSelf = user.username === account.username;
const isProtected = isSelf || user.systemAccount;
return (
<div
key={user.username}
className="flex flex-wrap items-center gap-2 rounded-md bg-neutral-800/60 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate">
{user.username}
{user.systemAccount && (
<span className="ml-2 text-xs text-neutral-500">
{t('settings.account.users.deviceOwner')}
</span>
)}
</span>
<Select<UserRole>
className="w-28"
value={user.role}
disabled={isLoading || isProtected}
options={[
{ value: 'admin', label: t('settings.account.roles.admin') },
{ value: 'user', label: t('settings.account.roles.user') }
]}
onChange={(role) => updateUser(user, { role })}
/>
<Switch
checked={user.enabled}
disabled={isLoading || isProtected}
checkedChildren={t('settings.account.users.enabled')}
unCheckedChildren={t('settings.account.users.disabled')}
onChange={(enabled) => updateUser(user, { enabled })}
/>
<Button
size="small"
disabled={isLoading || isProtected}
onClick={() => setPasswordUser(user.username)}
>
{t('settings.account.users.resetPassword')}
</Button>
<Popconfirm
title={t('settings.account.users.deleteConfirm')}
okText={t('settings.account.okBtn')}
cancelText={t('settings.account.cancelBtn')}
disabled={isProtected}
onConfirm={() => deleteUser(user.username)}
>
<Button danger size="small" disabled={isLoading || isProtected}>
{t('settings.account.users.delete')}
</Button>
</Popconfirm>
</div>
);
})}
</div>
<Modal
title={t('settings.account.users.create')}
open={isCreateOpen}
footer={null}
destroyOnHidden
onCancel={() => setIsCreateOpen(false)}
>
<Form<CreateValues>
form={createForm}
layout="vertical"
initialValues={{ role: 'user' }}
onFinish={createUser}
>
<Form.Item
name="username"
label={t('auth.placeholderUsername')}
rules={[
{ required: true, message: t('auth.noEmptyUsername') },
{ pattern: /^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$/, message: t('auth.illegalUsername') }
]}
>
<Input autoComplete="off" />
</Form.Item>
<Form.Item
name="password"
label={t('auth.placeholderPassword')}
rules={[
{ required: true, message: t('auth.noEmptyPassword') },
{ min: 8, max: 72, message: t('auth.passwordLength') }
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item name="role" label={t('settings.account.role')}>
<Select
options={[
{ value: 'admin', label: t('settings.account.roles.admin') },
{ value: 'user', label: t('settings.account.roles.user') }
]}
/>
</Form.Item>
<Button type="primary" htmlType="submit" loading={isLoading} className="w-full">
{t('settings.account.users.create')}
</Button>
</Form>
</Modal>
<Modal
title={t('settings.account.users.resetPassword')}
open={!!passwordUser}
footer={null}
destroyOnHidden
onCancel={() => setPasswordUser(null)}
>
<Form<PasswordValues> form={passwordForm} layout="vertical" onFinish={resetPassword}>
<Form.Item
name="password"
label={t('auth.placeholderPassword')}
rules={[
{ required: true, message: t('auth.noEmptyPassword') },
{ min: 8, max: 72, message: t('auth.passwordLength') }
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={isLoading} className="w-full">
{t('auth.ok')}
</Button>
</Form>
</Modal>
</>
);
};

View File

@@ -1,3 +1,4 @@
import { useAuth } from '@/contexts/auth.ts';
import { Divider } from 'antd';
import { useTranslation } from 'react-i18next';
@@ -9,6 +10,7 @@ import { WebTitle } from './web-title.tsx';
export const Appearance = () => {
const { t } = useTranslation();
const { account } = useAuth();
return (
<>
@@ -17,7 +19,7 @@ export const Appearance = () => {
<div className="text-neutral-400">{t('settings.appearance.display')}</div>
<Language />
<WebTitle />
{account.role === 'admin' && <WebTitle />}
<Divider className="opacity-50" style={{ margin: '32px 0' }} />

View File

@@ -1,3 +1,4 @@
import { useAuth } from '@/contexts/auth.ts';
import { Switch } from 'antd';
import { useAtom } from 'jotai';
import {
@@ -18,6 +19,7 @@ import { Robot } from '@/components/icons/robot.tsx';
export const MenuIcons = () => {
const { t } = useTranslation();
const { account } = useAuth();
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
@@ -31,7 +33,11 @@ export const MenuIcons = () => {
{ key: 'power', icon: <PowerIcon size={16} /> },
{ key: 'fullscreen', icon: <MaximizeIcon size={16} />, label: 'fullscreen.toggle' },
{ key: 'collapse', icon: <XIcon size={16} />, label: 'menu.collapse' }
];
].filter(
(item) =>
account.role === 'admin' ||
!['image', 'download', 'terminal', 'script', 'picoclaw'].includes(item.key)
);
function updateItems(key: string) {
const exist = menuDisabledItems.includes(key);

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useAuth } from '@/contexts/auth.ts';
import { Badge, Modal, Tooltip } from 'antd';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
@@ -33,6 +34,8 @@ import { Update } from './update';
export const Settings = () => {
const { t } = useTranslation();
const { account } = useAuth();
const isAdmin = account.role === 'admin';
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLocked, setIsLocked] = useState(false);
@@ -46,28 +49,33 @@ export const Settings = () => {
const tabs = [
{ id: 'about', icon: <BadgeInfoIcon size={16} />, component: <About /> },
{ id: 'appearance', icon: <PaletteIcon size={16} />, component: <Appearance /> },
{ id: 'device', icon: <SmartphoneIcon size={16} />, component: <Device /> },
{ id: 'network', icon: <NetworkIcon size={16} />, component: <Network /> },
{ id: 'mcp', icon: <BotIcon size={16} />, component: <MCP /> },
{
id: 'tailscale',
icon: <TailscaleIcon />,
component: <Tailscale setIsLocked={setIsLocked} />
},
{
id: 'update',
icon: <CircleArrowUpIcon size={16} />,
component: <Update setIsLocked={setIsLocked} />
},
...(isAdmin
? [
{ id: 'device', icon: <SmartphoneIcon size={16} />, component: <Device /> },
{ id: 'network', icon: <NetworkIcon size={16} />, component: <Network /> },
{ id: 'mcp', icon: <BotIcon size={16} />, component: <MCP /> },
{
id: 'tailscale',
icon: <TailscaleIcon />,
component: <Tailscale setIsLocked={setIsLocked} />
},
{
id: 'update',
icon: <CircleArrowUpIcon size={16} />,
component: <Update setIsLocked={setIsLocked} />
}
]
: []),
{ id: 'account', icon: <UserRoundIcon size={18} />, component: <Account /> }
];
useEffect(() => {
if (!isAdmin) return;
const skip = ls.getSkipUpdate();
if (!skip) {
checkForUpdates();
}
}, []);
}, [isAdmin]);
useEffect(() => {
scrollViewportRef.current?.scrollTo({ top: 0, left: 0 });

View File

@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import '@xterm/xterm/css/xterm.css';
import { notifyAuthExpired } from '@/lib/auth-events.ts';
import { getBaseUrl } from '@/lib/service.ts';
import { Head } from '@/components/head.tsx';
@@ -31,6 +32,12 @@ export const Terminal = () => {
const ws = new WebSocket(url);
let isPicocomRunning = false;
ws.addEventListener('close', (event) => {
if (event.code === 4401) {
notifyAuthExpired();
}
});
ws.onopen = () => {
const attachAddon = new AttachAddon(ws);
terminal.loadAddon(attachAddon);

View File

@@ -1,6 +1,6 @@
import { createHashRouter } from 'react-router-dom';
import { createHashRouter, Outlet } from 'react-router-dom';
import { ProtectedRoute } from '@/components/auth';
import { AdminRoute, ProtectedRoute } from '@/components/auth';
import { Root } from '@/components/root';
export const router = createHashRouter([
@@ -27,11 +27,20 @@ export const router = createHashRouter([
}
},
{
path: 'terminal',
lazy: async () => {
const { Terminal } = await import('./pages/terminal');
return { Component: Terminal };
}
element: (
<AdminRoute>
<Outlet />
</AdminRoute>
),
children: [
{
path: 'terminal',
lazy: async () => {
const { Terminal } = await import('./pages/terminal');
return { Component: Terminal };
}
}
]
},
{
path: 'auth/password',