mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
feat: add secure multi-user support (#876)
This commit is contained in:
@@ -48,7 +48,7 @@ authentication: enable # Whether to enable identity verification fo
|
|||||||
jwt:
|
jwt:
|
||||||
secretKey: "" # The secret key used to sign and verify JWT Tokens. If left empty, a random key will be generated automatically on startup
|
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)
|
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:
|
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`
|
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`
|
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
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ authentication: enable # 是否开启 HTTP 接口与网页的身份
|
|||||||
jwt:
|
jwt:
|
||||||
secretKey: "" # 用于签发和验证 JWT Token 的密钥。如果不填,服务启动时将自动随机生成
|
secretKey: "" # 用于签发和验证 JWT Token 的密钥。如果不填,服务启动时将自动随机生成
|
||||||
refreshTokenDuration: 2678400 # 登录超时的刷新周期(单位:秒)。默认为 `2678400`(约31天)
|
refreshTokenDuration: 2678400 # 登录超时的刷新周期(单位:秒)。默认为 `2678400`(约31天)
|
||||||
revokeTokensOnLogout: true # 退出登录时是否废除所有现存的 Token。启用此项可以在注销时轮换 SecretKey,强迫所有终端重新登录。默认为 `true`
|
revokeTokensOnLogout: true # 退出登录时是否废除该用户的全部会话;不会影响其他用户。设为 false 时仅清除浏览器 Cookie,不推荐使用。默认为 `true`
|
||||||
security:
|
security:
|
||||||
loginLockoutDuration: 0, # 达到失败上限后,禁止该 IP 再次尝试登录的持续时间(单位:秒)。如果设为 `0` 或不填,则代表不开启防暴力破解功能。默认为 `0`
|
loginLockoutDuration: 0, # 达到失败上限后,禁止该 IP 再次尝试登录的持续时间(单位:秒)。如果设为 `0` 或不填,则代表不开启防暴力破解功能。默认为 `0`
|
||||||
loginMaxFailures: 5, # 允许触发保护前,单个 IP 连续登录失败的最大次数。默认为 `5`
|
loginMaxFailures: 5, # 允许触发保护前,单个 IP 连续登录失败的最大次数。默认为 `5`
|
||||||
@@ -60,6 +60,22 @@ turn:
|
|||||||
turnCred: example_cred # 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 下使用。**
|
**注意:请使用 Linux 操作系统(x86-64)和 Go 1.25 或更高版本。该工具链无法在 ARM、Windows 或 macOS 下使用。**
|
||||||
|
|||||||
616
server/authn/store.go
Normal file
616
server/authn/store.go
Normal 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 ©
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ©
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fallback != nil {
|
||||||
|
db.LegacyUsername = fallback.Username
|
||||||
|
db.LegacyPassword = fallback.PasswordHash
|
||||||
|
}
|
||||||
|
}
|
||||||
212
server/authn/store_test.go
Normal file
212
server/authn/store_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegenerateSecretKey regenerate secret key when logout
|
|
||||||
func RegenerateSecretKey() {
|
|
||||||
if instance.JWT.RevokeTokensOnLogout {
|
|
||||||
instance.JWT.SecretKey = generateRandomSecretKey()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate random string for secret key.
|
// Generate random string for secret key.
|
||||||
func generateRandomSecretKey() string {
|
func generateRandomSecretKey() string {
|
||||||
b := make([]byte, 64)
|
b := make([]byte, 64)
|
||||||
|
|||||||
@@ -1,68 +1,160 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
|
"NanoKVM-Server/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
log "github.com/sirupsen/logrus"
|
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 {
|
type Token struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
TokenVersion uint64 `json:"tokenVersion"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckToken() gin.HandlerFunc {
|
func CheckToken() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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()
|
c.Next()
|
||||||
return
|
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 {
|
func CheckLoopbackInternalToken() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if allowByLoopbackInternalToken(c.Request) {
|
if allowByLoopbackInternalToken(c.Request) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
abortUnauthorized(c)
|
abortUnauthorized(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc {
|
func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if allowByToken(c) || allowByLoopbackInternalToken(c.Request) {
|
if allowByLoopbackInternalToken(c.Request) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
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()
|
conf := config.GetInstance()
|
||||||
|
|
||||||
if conf.Authentication == "disable" {
|
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 {
|
if err != nil {
|
||||||
return false
|
return Principal{}, nil, false
|
||||||
}
|
}
|
||||||
|
token, err := ParseJWT(cookie)
|
||||||
_, err = ParseJWT(cookie)
|
if err != nil {
|
||||||
return 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) {
|
func abortUnauthorized(c *gin.Context) {
|
||||||
@@ -70,37 +162,44 @@ func abortUnauthorized(c *gin.Context) {
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateJWT(username string) (string, error) {
|
func GenerateJWT(username string, tokenVersion uint64) (string, error) {
|
||||||
conf := config.GetInstance()
|
conf := config.GetInstance()
|
||||||
|
now := time.Now()
|
||||||
expireDuration := time.Duration(conf.JWT.RefreshTokenDuration) * time.Second
|
expireDuration := time.Duration(conf.JWT.RefreshTokenDuration) * time.Second
|
||||||
|
|
||||||
claims := Token{
|
claims := Token{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
TokenVersion: tokenVersion,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expireDuration)),
|
Subject: username,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(expireDuration)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
return token.SignedString([]byte(conf.JWT.SecretKey))
|
||||||
|
|
||||||
return t.SignedString([]byte(conf.JWT.SecretKey))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseJWT(jwtToken string) (*Token, error) {
|
func ParseJWT(jwtToken string) (*Token, error) {
|
||||||
conf := config.GetInstance()
|
conf := config.GetInstance()
|
||||||
|
parsed, err := jwt.ParseWithClaims(
|
||||||
t, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (interface{}, error) {
|
jwtToken,
|
||||||
return []byte(conf.JWT.SecretKey), nil
|
&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 {
|
if err != nil {
|
||||||
log.Debugf("parse jwt error: %s", err)
|
log.Debugf("parse jwt error: %s", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
claims, ok := parsed.Claims.(*Token)
|
||||||
if claims, ok := t.Claims.(*Token); ok && t.Valid {
|
if !ok || !parsed.Valid || claims.Username == "" || claims.Subject != claims.Username || claims.TokenVersion == 0 {
|
||||||
return claims, nil
|
return nil, errors.New("invalid token claims")
|
||||||
} else {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|||||||
230
server/middleware/jwt_test.go
Normal file
230
server/middleware/jwt_test.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
123
server/middleware/session.go
Normal file
123
server/middleware/session.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,19 +5,38 @@ type LoginReq struct {
|
|||||||
Password string `validate:"required"`
|
Password string `validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginRsp struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetAccountRsp struct {
|
type GetAccountRsp struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangePasswordReq struct {
|
type ChangePasswordReq struct {
|
||||||
Username string `json:"username" validate:"required"`
|
CurrentPassword string `json:"currentPassword"`
|
||||||
Password string `json:"password" validate:"required"`
|
Password string `json:"password" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type IsPasswordUpdatedRsp struct {
|
type IsPasswordUpdatedRsp struct {
|
||||||
IsUpdated bool `json:"isUpdated"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func ValidateRequest(req interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if env == "" || env == "debug" {
|
if env == "" || env == "debug" {
|
||||||
log.Debugf("request: %+v\n", req)
|
log.Debugf("request validated: %T", req)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/middleware"
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/application"
|
"NanoKVM-Server/service/application"
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ import (
|
|||||||
|
|
||||||
func applicationRouter(r *gin.Engine) {
|
func applicationRouter(r *gin.Engine) {
|
||||||
service := application.NewService()
|
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.GET("/application/version", service.GetVersion) // get application version
|
||||||
api.POST("/application/update", service.Update) // update application
|
api.POST("/application/update", service.Update) // update application
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package router
|
|||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/middleware"
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/auth"
|
"NanoKVM-Server/service/auth"
|
||||||
)
|
)
|
||||||
@@ -18,4 +19,14 @@ func authRouter(r *gin.Engine) {
|
|||||||
api.GET("/auth/account", service.GetAccount) // get account
|
api.GET("/auth/account", service.GetAccount) // get account
|
||||||
api.POST("/auth/password", service.ChangePassword) // change password
|
api.POST("/auth/password", service.ChangePassword) // change password
|
||||||
api.POST("/auth/logout", service.Logout) // logout
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/middleware"
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/controlmode"
|
"NanoKVM-Server/service/controlmode"
|
||||||
"NanoKVM-Server/service/hid"
|
"NanoKVM-Server/service/hid"
|
||||||
@@ -19,7 +20,10 @@ type setAIControlModeRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func controlRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
|
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) {
|
group.GET("/status", func(c *gin.Context) {
|
||||||
status, err := control.Status()
|
status, err := control.Status()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/service/download"
|
"NanoKVM-Server/service/download"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ import (
|
|||||||
|
|
||||||
func downloadRouter(r *gin.Engine) {
|
func downloadRouter(r *gin.Engine) {
|
||||||
service := download.NewService()
|
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", service.DownloadImage) // download image
|
||||||
api.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download
|
api.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/middleware"
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/extensions/tailscale"
|
"NanoKVM-Server/service/extensions/tailscale"
|
||||||
|
|
||||||
@@ -8,7 +9,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func extensionsRouter(r *gin.Engine) {
|
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()
|
ts := tailscale.NewService()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package router
|
|||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/middleware"
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/hid"
|
"NanoKVM-Server/service/hid"
|
||||||
)
|
)
|
||||||
@@ -20,17 +21,21 @@ func hidRouter(r *gin.Engine) {
|
|||||||
|
|
||||||
api.POST("/hid/paste", service.Paste) // paste
|
api.POST("/hid/paste", service.Paste) // paste
|
||||||
|
|
||||||
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
|
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
|
||||||
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
|
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key
|
||||||
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
|
|
||||||
|
|
||||||
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key
|
api.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||||
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/leds", service.GetKeyboardLedStatus)
|
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)
|
localAPI.POST("/usb/recover", service.RecoverUSB)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package router
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/middleware"
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/controlmode"
|
"NanoKVM-Server/service/controlmode"
|
||||||
"NanoKVM-Server/service/hid"
|
"NanoKVM-Server/service/hid"
|
||||||
@@ -23,7 +24,10 @@ func mcpRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *pic
|
|||||||
picoclawService.PublishControlModeChangedFrom(status, "mcp_config")
|
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.GET("/config", service.GetConfig)
|
||||||
management.POST("/config", service.SetConfig)
|
management.POST("/config", service.SetConfig)
|
||||||
management.POST("/key/regenerate", service.RegenerateAPIKey)
|
management.POST("/key/regenerate", service.RegenerateAPIKey)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package router
|
|||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/middleware"
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/network"
|
"NanoKVM-Server/service/network"
|
||||||
)
|
)
|
||||||
@@ -15,15 +16,19 @@ func networkRouter(r *gin.Engine) {
|
|||||||
|
|
||||||
api := r.Group("/api").Use(middleware.CheckToken())
|
api := r.Group("/api").Use(middleware.CheckToken())
|
||||||
|
|
||||||
api.POST("/network/wol", service.WakeOnLAN) // wake on lan
|
api.POST("/network/wol", service.WakeOnLAN) // wake on lan
|
||||||
api.GET("/network/wol/mac", service.GetMac) // get mac list
|
api.GET("/network/wol/mac", service.GetMac) // get mac list
|
||||||
api.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
|
|
||||||
api.POST("/network/wol/mac/name", service.SetMacName) // set mac name
|
|
||||||
|
|
||||||
api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
|
admin := r.Group("/api").Use(
|
||||||
api.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
|
middleware.CheckToken(),
|
||||||
api.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
|
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
|
admin.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
|
||||||
api.POST("/network/dns", service.SetDNS) // set DNS configuration
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package router
|
|||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/middleware"
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/picoclaw"
|
"NanoKVM-Server/service/picoclaw"
|
||||||
)
|
)
|
||||||
@@ -39,7 +40,10 @@ func PicoclawLoopbackHTTPAllowedPaths() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func picoclawRouter(r *gin.Engine, service *picoclaw.Service) {
|
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 := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())
|
||||||
|
|
||||||
localAPI.GET(picoclawScreenshotPath, service.Screenshot)
|
localAPI.GET(picoclawScreenshotPath, service.Screenshot)
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ package router
|
|||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/middleware"
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/storage"
|
"NanoKVM-Server/service/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func storageRouter(r *gin.Engine) {
|
func storageRouter(r *gin.Engine) {
|
||||||
service := storage.NewService()
|
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", service.GetImages) // get image list
|
||||||
api.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image
|
api.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package router
|
|||||||
import (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/middleware"
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/vm"
|
"NanoKVM-Server/service/vm"
|
||||||
)
|
)
|
||||||
@@ -11,6 +12,10 @@ func vmRouter(r *gin.Engine) {
|
|||||||
service := vm.NewService()
|
service := vm.NewService()
|
||||||
|
|
||||||
api := r.Group("/api").Use(middleware.CheckToken())
|
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/info", service.GetInfo) // get device information
|
||||||
api.GET("/vm/hardware", service.GetHardware) // get hardware version
|
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.GET("/vm/gpio", service.GetGpio) // get gpio
|
||||||
api.POST("/vm/screen", service.SetScreen) // update screen
|
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
|
admin.GET("/vm/script", service.GetScripts) // get script
|
||||||
api.POST("/vm/script/upload", service.UploadScript) // upload script
|
admin.POST("/vm/script/upload", service.UploadScript) // upload script
|
||||||
api.POST("/vm/script/run", service.RunScript) // run script
|
admin.POST("/vm/script/run", service.RunScript) // run script
|
||||||
api.DELETE("/vm/script", service.DeleteScript) // delete script
|
admin.DELETE("/vm/script", service.DeleteScript) // delete script
|
||||||
|
|
||||||
api.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
|
admin.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
|
||||||
api.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device
|
admin.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device
|
||||||
|
|
||||||
api.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
|
admin.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
|
||||||
api.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit
|
admin.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit
|
||||||
|
|
||||||
api.GET("/vm/oled", service.GetOLED) // get OLED configuration
|
admin.GET("/vm/oled", service.GetOLED) // get OLED configuration
|
||||||
api.POST("/vm/oled", service.SetOLED) // set OLED configuration
|
admin.POST("/vm/oled", service.SetOLED) // set OLED configuration
|
||||||
|
|
||||||
// Only supported by PCIe version
|
// Only supported by PCIe version
|
||||||
api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
|
api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
|
||||||
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
|
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
|
||||||
api.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
|
admin.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
|
||||||
api.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
|
admin.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
|
||||||
api.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
|
admin.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
|
||||||
|
|
||||||
api.GET("/vm/ssh", service.GetSSHState) // get SSH state
|
admin.GET("/vm/ssh", service.GetSSHState) // get SSH state
|
||||||
api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
|
admin.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
|
||||||
api.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH
|
admin.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH
|
||||||
|
|
||||||
api.GET("/vm/swap", service.GetSwap) // get swap file size
|
admin.GET("/vm/swap", service.GetSwap) // get swap file size
|
||||||
api.POST("/vm/swap", service.SetSwap) // set swap file size
|
admin.POST("/vm/swap", service.SetSwap) // set swap file size
|
||||||
|
|
||||||
api.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
|
admin.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
|
||||||
api.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) // set mouse jiggler
|
admin.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) // set mouse jiggler
|
||||||
|
|
||||||
api.GET("/vm/hostname", service.GetHostname) // Get Hostname
|
api.GET("/vm/hostname", service.GetHostname) // Get Hostname
|
||||||
api.POST("/vm/hostname", service.SetHostname) // Set Hostname
|
admin.POST("/vm/hostname", service.SetHostname) // Set Hostname
|
||||||
|
|
||||||
api.GET("/vm/web-title", service.GetWebTitle) // Get web title
|
api.GET("/vm/web-title", service.GetWebTitle) // Get web title
|
||||||
api.POST("/vm/web-title", service.SetWebTitle) // Set web title
|
admin.POST("/vm/web-title", service.SetWebTitle) // Set web title
|
||||||
|
|
||||||
api.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
|
admin.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
|
||||||
api.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
|
admin.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
|
||||||
api.POST("/vm/mdns/disable", service.DisableMdns) // disable 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
|
admin.GET("/vm/autostart", service.GetAutostart) // get autostart list
|
||||||
api.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
|
admin.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
|
||||||
api.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
|
admin.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
|
||||||
api.POST("/vm/autostart/:name", service.UploadAutostart) // upload 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
"NanoKVM-Server/config"
|
"NanoKVM-Server/config"
|
||||||
"NanoKVM-Server/middleware"
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/proto"
|
"NanoKVM-Server/proto"
|
||||||
|
"NanoKVM-Server/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -15,12 +19,9 @@ func (s *Service) Login(c *gin.Context) {
|
|||||||
var req proto.LoginReq
|
var req proto.LoginReq
|
||||||
var rsp proto.Response
|
var rsp proto.Response
|
||||||
|
|
||||||
// authentication disabled
|
|
||||||
conf := config.GetInstance()
|
conf := config.GetInstance()
|
||||||
if conf.Authentication == "disable" {
|
if conf.Authentication == "disable" {
|
||||||
rsp.OkRspWithData(c, &proto.LoginRsp{
|
rsp.OkRsp(c)
|
||||||
Token: "disabled",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,63 +31,98 @@ func (s *Service) Login(c *gin.Context) {
|
|||||||
rsp.ErrRsp(c, code, msg)
|
rsp.ErrRsp(c, code, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||||
time.Sleep(3 * time.Second)
|
time.Sleep(3 * time.Second)
|
||||||
rsp.ErrRsp(c, -1, "invalid parameters")
|
rsp.ErrRsp(c, -1, "invalid parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
password, err := utils.DecodeDecrypt(req.Password)
|
||||||
if ok := CompareAccount(req.Username, req.Password); !ok {
|
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)
|
time.Sleep(2 * time.Second)
|
||||||
|
rsp.ErrRsp(c, -3, "authentication unavailable")
|
||||||
if locked, code, msg := RecordLoginFailure(clientIP); locked {
|
return
|
||||||
rsp.ErrRsp(c, code, msg)
|
}
|
||||||
return
|
if !ok {
|
||||||
}
|
s.loginFailed(c, clientIP, &rsp)
|
||||||
|
|
||||||
rsp.ErrRsp(c, -2, "invalid username or password")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ClearLoginAttempt(clientIP)
|
ClearLoginAttempt(clientIP)
|
||||||
|
token, err := middleware.GenerateJWT(user.Username, user.TokenVersion)
|
||||||
token, err := middleware.GenerateJWT(req.Username)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(time.Second)
|
||||||
rsp.ErrRsp(c, -3, "generate token failed")
|
rsp.ErrRsp(c, -3, "generate token failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setSessionCookie(c, token)
|
||||||
|
rsp.OkRsp(c)
|
||||||
|
log.Infof("user logged in: %s", user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
rsp.OkRspWithData(c, &proto.LoginRsp{
|
func (s *Service) loginFailed(c *gin.Context, clientIP string, rsp *proto.Response) {
|
||||||
Token: token,
|
time.Sleep(2 * time.Second)
|
||||||
})
|
if locked, code, msg := RecordLoginFailure(clientIP); locked {
|
||||||
|
rsp.ErrRsp(c, code, msg)
|
||||||
log.Debugf("login success, username: %s", req.Username)
|
return
|
||||||
|
}
|
||||||
|
rsp.ErrRsp(c, -2, "invalid username or password")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Logout(c *gin.Context) {
|
func (s *Service) Logout(c *gin.Context) {
|
||||||
conf := config.GetInstance()
|
|
||||||
|
|
||||||
if conf.JWT.RevokeTokensOnLogout {
|
|
||||||
config.RegenerateSecretKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
var rsp proto.Response
|
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)
|
rsp.OkRsp(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetAccount(c *gin.Context) {
|
func (s *Service) GetAccount(c *gin.Context) {
|
||||||
var rsp proto.Response
|
var rsp proto.Response
|
||||||
|
principal, ok := middleware.CurrentPrincipal(c)
|
||||||
account, err := GetAccount()
|
if !ok {
|
||||||
if err != nil {
|
|
||||||
rsp.ErrRsp(c, -1, "get account failed")
|
rsp.ErrRsp(c, -1, "get account failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rsp.OkRspWithData(c, &proto.GetAccountRsp{
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +1,126 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"NanoKVM-Server/proto"
|
|
||||||
"NanoKVM-Server/utils"
|
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"NanoKVM-Server/authn"
|
||||||
|
"NanoKVM-Server/config"
|
||||||
|
"NanoKVM-Server/middleware"
|
||||||
|
"NanoKVM-Server/proto"
|
||||||
|
"NanoKVM-Server/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var systemPasswordUpdater = changeRootPassword
|
||||||
|
|
||||||
func (s *Service) ChangePassword(c *gin.Context) {
|
func (s *Service) ChangePassword(c *gin.Context) {
|
||||||
var req proto.ChangePasswordReq
|
var req proto.ChangePasswordReq
|
||||||
var rsp proto.Response
|
var rsp proto.Response
|
||||||
|
|
||||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||||
rsp.ErrRsp(c, -1, "invalid parameters")
|
rsp.ErrRsp(c, -1, "invalid parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
principal, ok := middleware.CurrentPrincipal(c)
|
||||||
password, err := utils.DecodeDecrypt(req.Password)
|
if !ok {
|
||||||
if err != nil || password == "" {
|
rsp.ErrRsp(c, -2, "invalid session")
|
||||||
rsp.ErrRsp(c, -2, "invalid password")
|
return
|
||||||
return
|
}
|
||||||
}
|
currentPassword, err := utils.DecodeDecrypt(req.CurrentPassword)
|
||||||
|
if err != nil || currentPassword == "" {
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
rsp.ErrRsp(c, -3, "current password is required")
|
||||||
if err != nil {
|
return
|
||||||
rsp.ErrRsp(c, -3, "failed to hash password")
|
}
|
||||||
return
|
if _, authenticated, authErr := authn.DefaultStore.Authenticate(principal.Username, currentPassword); authErr != nil {
|
||||||
}
|
rsp.ErrRsp(c, -4, "authentication unavailable")
|
||||||
|
return
|
||||||
if err = SetAccount(req.Username, string(hashedPassword)); err != nil {
|
} else if !authenticated {
|
||||||
rsp.ErrRsp(c, -4, "failed to save password")
|
rsp.ErrRsp(c, -3, "current password is incorrect")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := changeUserPassword(principal.Username, req.Password); err != nil {
|
||||||
// change root password
|
rsp.ErrRsp(c, -5, err.Error())
|
||||||
err = changeRootPassword(password)
|
|
||||||
if err != nil {
|
|
||||||
_ = DelAccount()
|
|
||||||
rsp.ErrRsp(c, -5, "failed to change password")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
middleware.RevokeUserSessions(principal.Username)
|
||||||
|
clearSessionCookie(c)
|
||||||
rsp.OkRsp(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) {
|
func (s *Service) IsPasswordUpdated(c *gin.Context) {
|
||||||
var rsp proto.Response
|
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 {
|
func changeUserPassword(username, encryptedPassword string) error {
|
||||||
rsp.OkRspWithData(c, &proto.IsPasswordUpdatedRsp{
|
password, err := utils.DecodeDecrypt(encryptedPassword)
|
||||||
IsUpdated: false,
|
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
|
||||||
}
|
}
|
||||||
|
_, err = authn.DefaultStore.SetPassword(username, password)
|
||||||
account, err := GetAccount()
|
return err
|
||||||
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),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func changeRootPassword(password string) error {
|
func changeRootPassword(password string) error {
|
||||||
err := passwd(password)
|
if err := passwd(password); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.Errorf("failed to change root password: %s", err)
|
log.Errorf("failed to change root password: %s", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
log.Debug("change root password successful")
|
||||||
log.Debugf("change root password successful.")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func passwd(password string) error {
|
func passwd(password string) error {
|
||||||
cmd := exec.Command("passwd", "root")
|
cmd := exec.Command("passwd", "root")
|
||||||
|
|
||||||
stdin, err := cmd.StdinPipe()
|
stdin, err := cmd.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() { _ = stdin.Close() }()
|
||||||
_ = stdin.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
if err = cmd.Start(); err != nil {
|
if err = cmd.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = io.WriteString(stdin, password+"\n"); err != nil {
|
if _, err = io.WriteString(stdin, password+"\n"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
if _, err = io.WriteString(stdin, password+"\n"); err != nil {
|
if _, err = io.WriteString(stdin, password+"\n"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return cmd.Wait()
|
||||||
if err = cmd.Wait(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
307
server/service/auth/service_test.go
Normal file
307
server/service/auth/service_test.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
127
server/service/auth/users.go
Normal file
127
server/service/auth/users.go
Normal 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
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/controlmode"
|
"NanoKVM-Server/service/controlmode"
|
||||||
"NanoKVM-Server/service/stream/mjpeg"
|
"NanoKVM-Server/service/stream/mjpeg"
|
||||||
|
|
||||||
@@ -19,9 +20,7 @@ import (
|
|||||||
var gatewayUpgrader = websocket.Upgrader{
|
var gatewayUpgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 4096,
|
ReadBufferSize: 4096,
|
||||||
WriteBufferSize: 4096,
|
WriteBufferSize: 4096,
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: middleware.CheckWebSocketOrigin,
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type relayResult struct {
|
type relayResult struct {
|
||||||
@@ -88,6 +87,8 @@ func (s *Service) ConnectGateway(c *gin.Context) {
|
|||||||
GetSessionManager().Remove(sessionID)
|
GetSessionManager().Remove(sessionID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), downstream)
|
||||||
|
defer stopSessionWatcher()
|
||||||
|
|
||||||
GetSessionManager().AttachUpstream(sessionID, upstream)
|
GetSessionManager().AttachUpstream(sessionID, upstream)
|
||||||
GetSessionManager().AttachDownstream(sessionID, downstream)
|
GetSessionManager().AttachDownstream(sessionID, downstream)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package direct
|
package direct
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"NanoKVM-Server/middleware"
|
||||||
"NanoKVM-Server/service/stream"
|
"NanoKVM-Server/service/stream"
|
||||||
"net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,9 +17,7 @@ var (
|
|||||||
streamer = newStreamer()
|
streamer = newStreamer()
|
||||||
upgrader = websocket.Upgrader{
|
upgrader = websocket.Upgrader{
|
||||||
WriteBufferSize: 256 * 1024,
|
WriteBufferSize: 256 * 1024,
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: middleware.CheckWebSocketOrigin,
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,6 +27,8 @@ func Connect(c *gin.Context) {
|
|||||||
log.Errorf("failed to upgrade to websocket: %s", err)
|
log.Errorf("failed to upgrade to websocket: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), ws)
|
||||||
|
defer stopSessionWatcher()
|
||||||
client := newClient(ws)
|
client := newClient(ws)
|
||||||
if flowWindow, err := strconv.Atoi(c.Query("flow")); err == nil && flowWindow > 0 {
|
if flowWindow, err := strconv.Atoi(c.Query("flow")); err == nil && flowWindow > 0 {
|
||||||
client.queue.enableFlowControl(flowWindow)
|
client.queue.enableFlowControl(flowWindow)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package webrtc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"NanoKVM-Server/config"
|
"NanoKVM-Server/config"
|
||||||
|
"NanoKVM-Server/middleware"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,9 +17,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
upgrader = websocket.Upgrader{
|
upgrader = websocket.Upgrader{
|
||||||
WriteBufferSize: 256 * 1024,
|
WriteBufferSize: 256 * 1024,
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: middleware.CheckWebSocketOrigin,
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
globalManager *WebRTCManager
|
globalManager *WebRTCManager
|
||||||
managerOnce sync.Once
|
managerOnce sync.Once
|
||||||
@@ -39,6 +37,8 @@ func Connect(c *gin.Context) {
|
|||||||
log.Errorf("failed to create h264 websocket: %s", err)
|
log.Errorf("failed to create h264 websocket: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), wsConn)
|
||||||
|
defer stopSessionWatcher()
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = wsConn.Close()
|
_ = wsConn.Close()
|
||||||
log.Debugf("h264 websocket disconnected: %s", c.ClientIP())
|
log.Debugf("h264 websocket disconnected: %s", c.ClientIP())
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package vm
|
package vm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"NanoKVM-Server/middleware"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,9 +26,7 @@ type WinSize struct {
|
|||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: maxMessageSize,
|
ReadBufferSize: maxMessageSize,
|
||||||
WriteBufferSize: maxMessageSize,
|
WriteBufferSize: maxMessageSize,
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: middleware.CheckWebSocketOrigin,
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Terminal(c *gin.Context) {
|
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)
|
log.Errorf("failed to init websocket: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), ws)
|
||||||
|
defer stopSessionWatcher()
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = ws.Close()
|
_ = ws.Close()
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"NanoKVM-Server/middleware"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
@@ -13,9 +13,7 @@ type Service struct{}
|
|||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 1024,
|
WriteBufferSize: 1024,
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: middleware.CheckWebSocketOrigin,
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService() *Service {
|
func NewService() *Service {
|
||||||
@@ -28,6 +26,8 @@ func (s *Service) Connect(c *gin.Context) {
|
|||||||
log.Errorf("create websocket failed: %s", err)
|
log.Errorf("create websocket failed: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
stopSessionWatcher := middleware.WatchWebSocket(c.Request.Context(), ws)
|
||||||
|
defer stopSessionWatcher()
|
||||||
|
|
||||||
log.Debug("websocket connected")
|
log.Debug("websocket connected")
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"i18next": "^23.16.8",
|
"i18next": "^23.16.8",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
"js-cookie": "^3.0.7",
|
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -49,7 +48,6 @@
|
|||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/js-cookie": "^3.0.6",
|
|
||||||
"@types/react": "^18.3.28",
|
"@types/react": "^18.3.28",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
|
|||||||
17
web/pnpm-lock.yaml
generated
17
web/pnpm-lock.yaml
generated
@@ -41,9 +41,6 @@ importers:
|
|||||||
jotai:
|
jotai:
|
||||||
specifier: ^2.18.0
|
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)
|
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:
|
lucide-react:
|
||||||
specifier: ^0.562.0
|
specifier: ^0.562.0
|
||||||
version: 0.562.0(react@18.3.1)
|
version: 0.562.0(react@18.3.1)
|
||||||
@@ -102,9 +99,6 @@ importers:
|
|||||||
'@types/crypto-js':
|
'@types/crypto-js':
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
'@types/js-cookie':
|
|
||||||
specifier: ^3.0.6
|
|
||||||
version: 3.0.6
|
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.28
|
specifier: ^18.3.28
|
||||||
version: 18.3.28
|
version: 18.3.28
|
||||||
@@ -1026,9 +1020,6 @@ packages:
|
|||||||
'@types/hast@3.0.4':
|
'@types/hast@3.0.4':
|
||||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
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':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
@@ -2025,10 +2016,6 @@ packages:
|
|||||||
react:
|
react:
|
||||||
optional: true
|
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:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -4269,8 +4256,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|
||||||
'@types/js-cookie@3.0.6': {}
|
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
@@ -5476,8 +5461,6 @@ snapshots:
|
|||||||
'@types/react': 18.3.28
|
'@types/react': 18.3.28
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
js-cookie@3.0.7: {}
|
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { http } from '@/lib/http';
|
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) {
|
export function login(username: string, password: string) {
|
||||||
const data = {
|
const data = {
|
||||||
username,
|
username,
|
||||||
@@ -16,14 +28,34 @@ export function getAccount() {
|
|||||||
return http.get('/api/auth/account');
|
return http.get('/api/auth/account');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changePassword(username: string, password: string) {
|
export function changePassword(currentPassword: string, password: string) {
|
||||||
const data = {
|
return http.post('/api/auth/password', { currentPassword, password });
|
||||||
username,
|
|
||||||
password
|
|
||||||
};
|
|
||||||
return http.post('/api/auth/password', data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPasswordUpdated() {
|
export function isPasswordUpdated() {
|
||||||
return http.get('/api/auth/password');
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { 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 }) => {
|
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 <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 />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { useOptionalAuth } from '@/contexts/auth.ts';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { Helmet, HelmetData } from 'react-helmet-async';
|
import { Helmet, HelmetData } from 'react-helmet-async';
|
||||||
|
|
||||||
import { getWebTitle } from '@/api/vm.ts';
|
import { getWebTitle } from '@/api/vm.ts';
|
||||||
import { existToken } from '@/lib/cookie.ts';
|
|
||||||
import { webTitleAtom } from '@/jotai/settings.ts';
|
import { webTitleAtom } from '@/jotai/settings.ts';
|
||||||
|
|
||||||
type HeadProps = {
|
type HeadProps = {
|
||||||
@@ -15,16 +15,17 @@ const helmetData = new HelmetData({});
|
|||||||
|
|
||||||
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
|
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
|
||||||
const [webTitle, setWebTitle] = useAtom(webTitleAtom);
|
const [webTitle, setWebTitle] = useAtom(webTitleAtom);
|
||||||
|
const auth = useOptionalAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!existToken()) return;
|
if (!auth) return;
|
||||||
|
|
||||||
getWebTitle().then((rsp) => {
|
getWebTitle().then((rsp) => {
|
||||||
if (rsp.data?.title) {
|
if (rsp.data?.title) {
|
||||||
setWebTitle(rsp.data.title);
|
setWebTitle(rsp.data.title);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, [auth, setWebTitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Helmet
|
<Helmet
|
||||||
|
|||||||
21
web/src/contexts/auth.ts
Normal file
21
web/src/contexts/auth.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -11,14 +11,17 @@ const en = {
|
|||||||
login: 'Login',
|
login: 'Login',
|
||||||
placeholderUsername: 'Username',
|
placeholderUsername: 'Username',
|
||||||
placeholderPassword: 'Password',
|
placeholderPassword: 'Password',
|
||||||
|
placeholderCurrentPassword: 'Current password',
|
||||||
placeholderPassword2: 'Please enter password again',
|
placeholderPassword2: 'Please enter password again',
|
||||||
noEmptyUsername: 'Username required',
|
noEmptyUsername: 'Username required',
|
||||||
noEmptyPassword: 'Password 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',
|
noAccount: 'Failed to get user information, please refresh web page or reset password',
|
||||||
invalidUser: 'Invalid username or password',
|
invalidUser: 'Invalid username or password',
|
||||||
locked: 'Too many logins, please try again later',
|
locked: 'Too many logins, please try again later',
|
||||||
globalLocked: 'System under protection, please try again later',
|
globalLocked: 'System under protection, please try again later',
|
||||||
error: 'Unexpected error',
|
error: 'Unexpected error',
|
||||||
|
invalidCurrentPassword: 'Current password is incorrect',
|
||||||
changePassword: 'Change Password',
|
changePassword: 'Change Password',
|
||||||
changePasswordDesc: 'For the security of your device, please change the password!',
|
changePasswordDesc: 'For the security of your device, please change the password!',
|
||||||
differentPassword: 'Passwords do not match',
|
differentPassword: 'Passwords do not match',
|
||||||
@@ -550,12 +553,33 @@ const en = {
|
|||||||
account: {
|
account: {
|
||||||
title: 'Account',
|
title: 'Account',
|
||||||
webAccount: 'Web Account Name',
|
webAccount: 'Web Account Name',
|
||||||
|
role: 'Role',
|
||||||
|
roles: {
|
||||||
|
admin: 'Administrator',
|
||||||
|
user: 'User'
|
||||||
|
},
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
updateBtn: 'Change',
|
updateBtn: 'Change',
|
||||||
logoutBtn: 'Logout',
|
logoutBtn: 'Logout',
|
||||||
logoutDesc: 'Are you sure you want to logout?',
|
logoutDesc: 'Are you sure you want to logout?',
|
||||||
okBtn: 'Yes',
|
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: {
|
picoclaw: {
|
||||||
|
|||||||
@@ -11,14 +11,17 @@ const zh = {
|
|||||||
login: '登录',
|
login: '登录',
|
||||||
placeholderUsername: '请输入用户名',
|
placeholderUsername: '请输入用户名',
|
||||||
placeholderPassword: '请输入密码',
|
placeholderPassword: '请输入密码',
|
||||||
|
placeholderCurrentPassword: '请输入当前密码',
|
||||||
placeholderPassword2: '请再次输入密码',
|
placeholderPassword2: '请再次输入密码',
|
||||||
noEmptyUsername: '用户名不能为空',
|
noEmptyUsername: '用户名不能为空',
|
||||||
noEmptyPassword: '密码不能为空',
|
noEmptyPassword: '密码不能为空',
|
||||||
|
passwordLength: '密码长度必须为 8 到 72 个字符',
|
||||||
noAccount: '获取用户信息失败,请刷新重试或重置密码',
|
noAccount: '获取用户信息失败,请刷新重试或重置密码',
|
||||||
invalidUser: '用户名或密码错误',
|
invalidUser: '用户名或密码错误',
|
||||||
locked: '登录太频繁,请稍后再试',
|
locked: '登录太频繁,请稍后再试',
|
||||||
globalLocked: '系统防爆破保护中,请稍后再试',
|
globalLocked: '系统防爆破保护中,请稍后再试',
|
||||||
error: '未知错误',
|
error: '未知错误',
|
||||||
|
invalidCurrentPassword: '当前密码错误',
|
||||||
changePassword: '修改密码',
|
changePassword: '修改密码',
|
||||||
changePasswordDesc: '为了您的设备安全,请修改密码!',
|
changePasswordDesc: '为了您的设备安全,请修改密码!',
|
||||||
differentPassword: '两次密码不一致',
|
differentPassword: '两次密码不一致',
|
||||||
@@ -539,12 +542,33 @@ const zh = {
|
|||||||
account: {
|
account: {
|
||||||
title: '帐号',
|
title: '帐号',
|
||||||
webAccount: '网页帐号',
|
webAccount: '网页帐号',
|
||||||
|
role: '角色',
|
||||||
|
roles: {
|
||||||
|
admin: '管理员',
|
||||||
|
user: '普通用户'
|
||||||
|
},
|
||||||
password: '密码',
|
password: '密码',
|
||||||
updateBtn: '修改',
|
updateBtn: '修改',
|
||||||
logoutBtn: '退出',
|
logoutBtn: '退出',
|
||||||
logoutDesc: '确定要退出吗?',
|
logoutDesc: '确定要退出吗?',
|
||||||
okBtn: '确定',
|
okBtn: '确定',
|
||||||
cancelBtn: '取消'
|
cancelBtn: '取消',
|
||||||
|
users: {
|
||||||
|
title: '用户管理',
|
||||||
|
create: '创建用户',
|
||||||
|
enabled: '已启用',
|
||||||
|
disabled: '已禁用',
|
||||||
|
deviceOwner: '设备所有者',
|
||||||
|
resetPassword: '重置密码',
|
||||||
|
delete: '删除',
|
||||||
|
deleteConfirm: '删除此用户并撤销其全部会话?',
|
||||||
|
created: '用户已创建',
|
||||||
|
deleted: '用户已删除',
|
||||||
|
passwordUpdated: '密码已更新',
|
||||||
|
loadFailed: '加载用户失败',
|
||||||
|
saveFailed: '保存用户失败',
|
||||||
|
deleteFailed: '删除用户失败'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
picoclaw: {
|
picoclaw: {
|
||||||
|
|||||||
5
web/src/lib/auth-events.ts
Normal file
5
web/src/lib/auth-events.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const AUTH_EXPIRED_EVENT = 'nano-kvm:auth-expired';
|
||||||
|
|
||||||
|
export function notifyAuthExpired() {
|
||||||
|
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT));
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
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';
|
import { getBaseUrl } from '@/lib/service.ts';
|
||||||
|
|
||||||
type Response = {
|
type Response = {
|
||||||
@@ -42,8 +42,7 @@ class Http {
|
|||||||
console.log(error);
|
console.log(error);
|
||||||
const code = error.response?.status;
|
const code = error.response?.status;
|
||||||
if (code === 401) {
|
if (code === 401) {
|
||||||
removeToken();
|
notifyAuthExpired();
|
||||||
window.location.reload();
|
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { getBaseUrl } from '@/lib/service.ts';
|
||||||
|
|
||||||
type MessageHandler = (message: IMessageEvent) => void;
|
type MessageHandler = (message: IMessageEvent) => void;
|
||||||
@@ -123,8 +124,16 @@ export class WsClient {
|
|||||||
this.startHeartbeat();
|
this.startHeartbeat();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleClose(): void {
|
private handleClose(event: ICloseEvent): void {
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
|
|
||||||
|
if (event.code === 4401) {
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
this.cleanup();
|
||||||
|
notifyAuthExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [
|
export const handlers = [
|
||||||
http.post('/api/auth/login', () => {
|
http.post('/api/auth/login', () => {
|
||||||
|
isLoggedIn = true;
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
code: 0,
|
code: 0,
|
||||||
data: {
|
data: {}
|
||||||
token: 'mocked_token',
|
});
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
]
|
http.get('/api/auth/account', () => {
|
||||||
export const worker = setupWorker(...handlers)
|
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);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import * as api from '@/api/auth.ts';
|
import * as api from '@/api/auth.ts';
|
||||||
import { existToken, setToken } from '@/lib/cookie.ts';
|
|
||||||
import { encrypt } from '@/lib/encrypt.ts';
|
import { encrypt } from '@/lib/encrypt.ts';
|
||||||
import { Head } from '@/components/head.tsx';
|
import { Head } from '@/components/head.tsx';
|
||||||
|
|
||||||
@@ -19,10 +18,15 @@ export const Login = (): ReactElement => {
|
|||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existToken()) {
|
api
|
||||||
navigate('/', { replace: true });
|
.getAccount()
|
||||||
}
|
.then((rsp) => {
|
||||||
}, []);
|
if (rsp.code === 0 && rsp.data?.username) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (msg) {
|
if (msg) {
|
||||||
@@ -51,10 +55,7 @@ export const Login = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMsg('');
|
setMsg('');
|
||||||
setToken(rsp.data.token);
|
|
||||||
|
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
window.location.reload();
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setMsg(t('auth.error'));
|
setMsg(t('auth.error'));
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { Button, Card, Form, Input } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import * as api from '@/api/auth.ts';
|
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 { encrypt } from '@/lib/encrypt.ts';
|
||||||
import { Head } from '@/components/head.tsx';
|
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 = () => {
|
export const Password = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { account } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (msg) {
|
if (msg) {
|
||||||
@@ -25,27 +31,22 @@ export const Password = () => {
|
|||||||
setMsg(t('auth.differentPassword'));
|
setMsg(t('auth.differentPassword'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!validateString(values.username)) {
|
const currentPassword = encrypt(values.currentPassword);
|
||||||
setMsg(t('auth.illegalUsername'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!validateString(values.password)) {
|
|
||||||
setMsg('auth.illegalPassword');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = values.username;
|
|
||||||
const password = encrypt(values.password);
|
const password = encrypt(values.password);
|
||||||
|
|
||||||
api
|
api
|
||||||
.changePassword(username, password)
|
.changePassword(currentPassword, password)
|
||||||
.then((rsp: any) => {
|
.then((rsp: any) => {
|
||||||
if (rsp.code !== 0) {
|
if (rsp.code !== 0) {
|
||||||
setMsg(t('auth.error'));
|
setMsg(
|
||||||
|
rsp.code === invalidCurrentPasswordCode
|
||||||
|
? t('auth.invalidCurrentPassword')
|
||||||
|
: t('auth.error')
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeToken();
|
notifyAuthExpired();
|
||||||
navigate('/auth/login', { replace: true });
|
navigate('/auth/login', { replace: true });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -53,11 +54,6 @@ export const Password = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateString(str: string) {
|
|
||||||
const regex = /['"\\/]/;
|
|
||||||
return !regex.test(str);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
window.location.replace('/');
|
window.location.replace('/');
|
||||||
}
|
}
|
||||||
@@ -75,30 +71,43 @@ export const Password = () => {
|
|||||||
onFinish={changePassword}
|
onFinish={changePassword}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="username"
|
name="currentPassword"
|
||||||
rules={[{ required: true, message: t('auth.noEmptyUsername'), min: 1 }]}
|
rules={[{ required: true, message: t('auth.noEmptyPassword') }]}
|
||||||
>
|
|
||||||
<Input prefix={<UserOutlined />} placeholder={t('auth.placeholderUsername')} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="password"
|
|
||||||
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
|
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
prefix={<LockOutlined />}
|
prefix={<LockOutlined />}
|
||||||
type="password"
|
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')}
|
placeholder={t('auth.placeholderPassword')}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="password2"
|
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
|
<Input
|
||||||
prefix={<LockOutlined />}
|
prefix={<LockOutlined />}
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
placeholder={t('auth.placeholderPassword2')}
|
placeholder={t('auth.placeholderPassword2')}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -116,16 +125,18 @@ export const Password = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Card>
|
{account.role === 'admin' && (
|
||||||
<div className="flex w-[450px] flex-col">
|
<Card>
|
||||||
<div>{t('auth.tips.change1')}</div>
|
<div className="flex w-[450px] flex-col">
|
||||||
<ul className="list-outside list-decimal">
|
<div>{t('auth.tips.change1')}</div>
|
||||||
<li>{t('auth.tips.change2')}</li>
|
<ul className="list-outside list-decimal">
|
||||||
<li>{t('auth.tips.change3')}</li>
|
<li>{t('auth.tips.change2')}</li>
|
||||||
</ul>
|
<li>{t('auth.tips.change3')}</li>
|
||||||
<div className="text-red-500">{t('auth.tips.change4')}</div>
|
</ul>
|
||||||
</div>
|
<div className="text-red-500">{t('auth.tips.change4')}</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/auth.ts';
|
||||||
import { Divider } from 'antd';
|
import { Divider } from 'antd';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
@@ -26,6 +27,8 @@ import { Wol } from './wol';
|
|||||||
|
|
||||||
export const Menu = () => {
|
export const Menu = () => {
|
||||||
const nodeRef = useRef<HTMLDivElement | null>(null);
|
const nodeRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const { account } = useAuth();
|
||||||
|
const isAdmin = account.role === 'admin';
|
||||||
|
|
||||||
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
|
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
|
||||||
const isKeyboardLedStatusVisible = useAtomValue(keyboardLedStatusVisibleAtom);
|
const isKeyboardLedStatusVisible = useAtomValue(keyboardLedStatusVisibleAtom);
|
||||||
@@ -104,17 +107,18 @@ export const Menu = () => {
|
|||||||
<Mouse />
|
<Mouse />
|
||||||
<Divider type="vertical" />
|
<Divider type="vertical" />
|
||||||
|
|
||||||
{isEnabled('image') && <Image />}
|
{isAdmin && isEnabled('image') && <Image />}
|
||||||
{isEnabled('download') && <DownloadImage />}
|
{isAdmin && isEnabled('download') && <DownloadImage />}
|
||||||
{isEnabled('terminal') && <Terminal />}
|
{isAdmin && isEnabled('terminal') && <Terminal />}
|
||||||
{isEnabled('script') && <Script />}
|
{isAdmin && isEnabled('script') && <Script />}
|
||||||
{isEnabled('wol') && <Wol />}
|
{isEnabled('wol') && <Wol />}
|
||||||
|
|
||||||
{['image', 'download', 'script', 'terminal', 'wol'].some(isEnabled) && (
|
{(isEnabled('wol') ||
|
||||||
|
(isAdmin && ['image', 'download', 'script', 'terminal'].some(isEnabled))) && (
|
||||||
<Divider type="vertical" />
|
<Divider type="vertical" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEnabled('picoclaw') && (
|
{isAdmin && isEnabled('picoclaw') && (
|
||||||
<>
|
<>
|
||||||
<Picoclaw />
|
<Picoclaw />
|
||||||
<Divider type="vertical" />
|
<Divider type="vertical" />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAuth } from '@/contexts/auth.ts';
|
||||||
import { KeyboardIcon } from 'lucide-react';
|
import { KeyboardIcon } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -10,13 +11,14 @@ import { VirtualKeyboard } from './virtual-keyboard.tsx';
|
|||||||
|
|
||||||
export const Keyboard = () => {
|
export const Keyboard = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { account } = useAuth();
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<Paste />
|
<Paste />
|
||||||
<VirtualKeyboard />
|
<VirtualKeyboard />
|
||||||
<Shortcuts />
|
<Shortcuts />
|
||||||
<LeaderKey />
|
{account.role === 'admin' && <LeaderKey />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/auth.ts';
|
||||||
import { Divider, Popover } from 'antd';
|
import { Divider, Popover } from 'antd';
|
||||||
import { CommandIcon } from 'lucide-react';
|
import { CommandIcon } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -12,6 +13,7 @@ import type { Shortcut as ShortcutInterface } from './types.ts';
|
|||||||
|
|
||||||
export const Shortcuts = () => {
|
export const Shortcuts = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { account } = useAuth();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
@@ -112,12 +114,14 @@ export const Shortcuts = () => {
|
|||||||
|
|
||||||
<Divider style={{ margin: '5px 0 5px 0' }} />
|
<Divider style={{ margin: '5px 0 5px 0' }} />
|
||||||
|
|
||||||
<Recorder
|
{account.role === 'admin' && (
|
||||||
shortcuts={customShortcuts}
|
<Recorder
|
||||||
addShortcut={addShortcut}
|
shortcuts={customShortcuts}
|
||||||
delShortcut={delShortcut}
|
addShortcut={addShortcut}
|
||||||
setIsRecording={setIsRecording}
|
delShortcut={delShortcut}
|
||||||
/>
|
setIsRecording={setIsRecording}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import * as api from '@/api/vm.ts';
|
import * as api from '@/api/vm.ts';
|
||||||
|
|
||||||
export const Hostname = () => {
|
export const Hostname = ({ editable = false }: { editable?: boolean }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -83,12 +83,14 @@ export const Hostname = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span>{hostname}</span>
|
<span>{hostname}</span>
|
||||||
<div
|
{editable && (
|
||||||
className="size-[16px] cursor-pointer text-neutral-500 hover:text-blue-500"
|
<div
|
||||||
onClick={showInput}
|
className="size-[16px] cursor-pointer text-neutral-500 hover:text-blue-500"
|
||||||
>
|
onClick={showInput}
|
||||||
<ClipboardPenIcon size={16} />
|
>
|
||||||
</div>
|
<ClipboardPenIcon size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/auth.ts';
|
||||||
import { Tooltip } from 'antd';
|
import { Tooltip } from 'antd';
|
||||||
import { CircleHelpIcon, EthernetPortIcon, WifiIcon } from 'lucide-react';
|
import { CircleHelpIcon, EthernetPortIcon, WifiIcon } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -24,6 +25,7 @@ type Info = {
|
|||||||
|
|
||||||
export const Information = () => {
|
export const Information = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { account } = useAuth();
|
||||||
|
|
||||||
const [information, setInformation] = useState<Info>();
|
const [information, setInformation] = useState<Info>();
|
||||||
|
|
||||||
@@ -106,7 +108,7 @@ export const Information = () => {
|
|||||||
<span>{information ? information.application : '-'}</span>
|
<span>{information ? information.application : '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Hostname />
|
<Hostname editable={account.role === 'admin'} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useAuth } from '@/contexts/auth.ts';
|
||||||
import { Button, Divider } from 'antd';
|
import { Button, Divider } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import * as api from '@/api/auth.ts';
|
|
||||||
|
|
||||||
import { Logout } from './logout.tsx';
|
import { Logout } from './logout.tsx';
|
||||||
|
import { Users } from './users.tsx';
|
||||||
|
|
||||||
export const Account = () => {
|
export const Account = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { account } = useAuth();
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.getAccount().then((rsp) => {
|
|
||||||
if (rsp.code === 0) {
|
|
||||||
setUsername(rsp.data.username);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function changePassword() {
|
function changePassword() {
|
||||||
navigate('/auth/password');
|
navigate('/auth/password');
|
||||||
@@ -33,7 +23,12 @@ export const Account = () => {
|
|||||||
<div className="flex flex-col space-y-8">
|
<div className="flex flex-col space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>{t('settings.account.webAccount')}</span>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -46,6 +41,13 @@ export const Account = () => {
|
|||||||
|
|
||||||
<Divider className="opacity-50" />
|
<Divider className="opacity-50" />
|
||||||
|
|
||||||
|
{account.role === 'admin' && (
|
||||||
|
<>
|
||||||
|
<Users />
|
||||||
|
<Divider className="opacity-50" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Logout />
|
<Logout />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import * as api from '@/api/auth.ts';
|
import * as api from '@/api/auth.ts';
|
||||||
import { removeToken } from '@/lib/cookie.ts';
|
import { notifyAuthExpired } from '@/lib/auth-events.ts';
|
||||||
|
|
||||||
export const Logout = () => {
|
export const Logout = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -17,7 +17,7 @@ export const Logout = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeToken();
|
notifyAuthExpired();
|
||||||
navigate('/auth/login');
|
navigate('/auth/login');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
256
web/src/pages/desktop/menu/settings/account/users.tsx
Normal file
256
web/src/pages/desktop/menu/settings/account/users.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAuth } from '@/contexts/auth.ts';
|
||||||
import { Divider } from 'antd';
|
import { Divider } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import { WebTitle } from './web-title.tsx';
|
|||||||
|
|
||||||
export const Appearance = () => {
|
export const Appearance = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { account } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -17,7 +19,7 @@ export const Appearance = () => {
|
|||||||
|
|
||||||
<div className="text-neutral-400">{t('settings.appearance.display')}</div>
|
<div className="text-neutral-400">{t('settings.appearance.display')}</div>
|
||||||
<Language />
|
<Language />
|
||||||
<WebTitle />
|
{account.role === 'admin' && <WebTitle />}
|
||||||
|
|
||||||
<Divider className="opacity-50" style={{ margin: '32px 0' }} />
|
<Divider className="opacity-50" style={{ margin: '32px 0' }} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAuth } from '@/contexts/auth.ts';
|
||||||
import { Switch } from 'antd';
|
import { Switch } from 'antd';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import { Robot } from '@/components/icons/robot.tsx';
|
|||||||
|
|
||||||
export const MenuIcons = () => {
|
export const MenuIcons = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { account } = useAuth();
|
||||||
|
|
||||||
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
|
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
|
||||||
|
|
||||||
@@ -31,7 +33,11 @@ export const MenuIcons = () => {
|
|||||||
{ key: 'power', icon: <PowerIcon size={16} /> },
|
{ key: 'power', icon: <PowerIcon size={16} /> },
|
||||||
{ key: 'fullscreen', icon: <MaximizeIcon size={16} />, label: 'fullscreen.toggle' },
|
{ key: 'fullscreen', icon: <MaximizeIcon size={16} />, label: 'fullscreen.toggle' },
|
||||||
{ key: 'collapse', icon: <XIcon size={16} />, label: 'menu.collapse' }
|
{ 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) {
|
function updateItems(key: string) {
|
||||||
const exist = menuDisabledItems.includes(key);
|
const exist = menuDisabledItems.includes(key);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useAuth } from '@/contexts/auth.ts';
|
||||||
import { Badge, Modal, Tooltip } from 'antd';
|
import { Badge, Modal, Tooltip } from 'antd';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
@@ -33,6 +34,8 @@ import { Update } from './update';
|
|||||||
|
|
||||||
export const Settings = () => {
|
export const Settings = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { account } = useAuth();
|
||||||
|
const isAdmin = account.role === 'admin';
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isLocked, setIsLocked] = useState(false);
|
const [isLocked, setIsLocked] = useState(false);
|
||||||
@@ -46,28 +49,33 @@ export const Settings = () => {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'about', icon: <BadgeInfoIcon size={16} />, component: <About /> },
|
{ id: 'about', icon: <BadgeInfoIcon size={16} />, component: <About /> },
|
||||||
{ id: 'appearance', icon: <PaletteIcon size={16} />, component: <Appearance /> },
|
{ id: 'appearance', icon: <PaletteIcon size={16} />, component: <Appearance /> },
|
||||||
{ id: 'device', icon: <SmartphoneIcon size={16} />, component: <Device /> },
|
...(isAdmin
|
||||||
{ id: 'network', icon: <NetworkIcon size={16} />, component: <Network /> },
|
? [
|
||||||
{ id: 'mcp', icon: <BotIcon size={16} />, component: <MCP /> },
|
{ id: 'device', icon: <SmartphoneIcon size={16} />, component: <Device /> },
|
||||||
{
|
{ id: 'network', icon: <NetworkIcon size={16} />, component: <Network /> },
|
||||||
id: 'tailscale',
|
{ id: 'mcp', icon: <BotIcon size={16} />, component: <MCP /> },
|
||||||
icon: <TailscaleIcon />,
|
{
|
||||||
component: <Tailscale setIsLocked={setIsLocked} />
|
id: 'tailscale',
|
||||||
},
|
icon: <TailscaleIcon />,
|
||||||
{
|
component: <Tailscale setIsLocked={setIsLocked} />
|
||||||
id: 'update',
|
},
|
||||||
icon: <CircleArrowUpIcon size={16} />,
|
{
|
||||||
component: <Update setIsLocked={setIsLocked} />
|
id: 'update',
|
||||||
},
|
icon: <CircleArrowUpIcon size={16} />,
|
||||||
|
component: <Update setIsLocked={setIsLocked} />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{ id: 'account', icon: <UserRoundIcon size={18} />, component: <Account /> }
|
{ id: 'account', icon: <UserRoundIcon size={18} />, component: <Account /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return;
|
||||||
const skip = ls.getSkipUpdate();
|
const skip = ls.getSkipUpdate();
|
||||||
if (!skip) {
|
if (!skip) {
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isAdmin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollViewportRef.current?.scrollTo({ top: 0, left: 0 });
|
scrollViewportRef.current?.scrollTo({ top: 0, left: 0 });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
|
||||||
|
import { notifyAuthExpired } from '@/lib/auth-events.ts';
|
||||||
import { getBaseUrl } from '@/lib/service.ts';
|
import { getBaseUrl } from '@/lib/service.ts';
|
||||||
import { Head } from '@/components/head.tsx';
|
import { Head } from '@/components/head.tsx';
|
||||||
|
|
||||||
@@ -31,6 +32,12 @@ export const Terminal = () => {
|
|||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
let isPicocomRunning = false;
|
let isPicocomRunning = false;
|
||||||
|
|
||||||
|
ws.addEventListener('close', (event) => {
|
||||||
|
if (event.code === 4401) {
|
||||||
|
notifyAuthExpired();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
const attachAddon = new AttachAddon(ws);
|
const attachAddon = new AttachAddon(ws);
|
||||||
terminal.loadAddon(attachAddon);
|
terminal.loadAddon(attachAddon);
|
||||||
|
|||||||
@@ -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';
|
import { Root } from '@/components/root';
|
||||||
|
|
||||||
export const router = createHashRouter([
|
export const router = createHashRouter([
|
||||||
@@ -27,11 +27,20 @@ export const router = createHashRouter([
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'terminal',
|
element: (
|
||||||
lazy: async () => {
|
<AdminRoute>
|
||||||
const { Terminal } = await import('./pages/terminal');
|
<Outlet />
|
||||||
return { Component: Terminal };
|
</AdminRoute>
|
||||||
}
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'terminal',
|
||||||
|
lazy: async () => {
|
||||||
|
const { Terminal } = await import('./pages/terminal');
|
||||||
|
return { Component: Terminal };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'auth/password',
|
path: 'auth/password',
|
||||||
|
|||||||
Reference in New Issue
Block a user