Feature(Go): Mouse/keyboard input + user management with users.json (Phase 5 + 7)
This commit is contained in:
@@ -10,7 +10,11 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -32,6 +36,12 @@ type User struct {
|
||||
PasswordHash string // SHA256(password+salt) in lowercase hex
|
||||
Salt string // empty for admin (matches C++ convention)
|
||||
Role string // "admin" or "viewer"
|
||||
// AllowedGroups restricts which device groups this user can view. Empty
|
||||
// slice = no device access (admin role is treated as "all" elsewhere
|
||||
// without consulting this list). Matches the C++ WebUser.allowed_groups
|
||||
// field one-for-one so users.json is interchangeable between the two
|
||||
// servers.
|
||||
AllowedGroups []string
|
||||
}
|
||||
|
||||
// Session is the authenticated state attached to a valid token.
|
||||
@@ -48,6 +58,10 @@ type Authenticator struct {
|
||||
users map[string]*User // username -> user
|
||||
tokens map[string]*Session // token -> session
|
||||
tokenExpire time.Duration
|
||||
// usersFile is the persistence target for non-admin accounts (admin
|
||||
// lives in env/master-password only and is never written out, matching
|
||||
// the C++ SaveUsers behavior). Empty disables persistence.
|
||||
usersFile string
|
||||
}
|
||||
|
||||
// New returns an empty Authenticator. Call AddUser to populate.
|
||||
@@ -92,6 +106,100 @@ func (a *Authenticator) AddAdminFromPlainPassword(username, plainPassword string
|
||||
})
|
||||
}
|
||||
|
||||
// CreateUser registers a new account, persists the user table, and returns
|
||||
// nil on success. Mirrors the C++ CWebService::CreateUser semantics:
|
||||
// - role must be "admin" or "viewer"
|
||||
// - "admin" is reserved for the bootstrap account and cannot be created
|
||||
// via this API
|
||||
// - duplicate usernames are rejected
|
||||
//
|
||||
// Password is hashed with a fresh per-user salt before being stored.
|
||||
func (a *Authenticator) CreateUser(username, plainPassword, role string, allowedGroups []string) error {
|
||||
if username == "" || plainPassword == "" {
|
||||
return errors.New("username and password required")
|
||||
}
|
||||
if username == "admin" {
|
||||
return errors.New("'admin' is reserved")
|
||||
}
|
||||
if role != "admin" && role != "viewer" {
|
||||
return errors.New("role must be 'admin' or 'viewer'")
|
||||
}
|
||||
|
||||
salt, err := NewSalt()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := User{
|
||||
Username: username,
|
||||
PasswordHash: HashPassword(plainPassword, salt),
|
||||
Salt: salt,
|
||||
Role: role,
|
||||
AllowedGroups: append([]string(nil), allowedGroups...),
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
if _, exists := a.users[username]; exists {
|
||||
a.mu.Unlock()
|
||||
return errors.New("user already exists")
|
||||
}
|
||||
a.users[username] = &u
|
||||
path := a.usersFile
|
||||
snapshot := a.snapshotPersistableLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
if path != "" {
|
||||
return writeUsersFile(path, snapshot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user account and persists the change. The bootstrap
|
||||
// admin (always Role=="admin" with empty Salt) is protected: deleting it
|
||||
// would lock everyone out of the UI with no way back in.
|
||||
func (a *Authenticator) DeleteUser(username string) error {
|
||||
if username == "" || username == "admin" {
|
||||
return errors.New("cannot delete admin")
|
||||
}
|
||||
a.mu.Lock()
|
||||
if _, ok := a.users[username]; !ok {
|
||||
a.mu.Unlock()
|
||||
return errors.New("user not found")
|
||||
}
|
||||
delete(a.users, username)
|
||||
// Also drop any live sessions belonging to that user so they don't
|
||||
// outlive their account.
|
||||
for tok, s := range a.tokens {
|
||||
if s.Username == username {
|
||||
delete(a.tokens, tok)
|
||||
}
|
||||
}
|
||||
path := a.usersFile
|
||||
snapshot := a.snapshotPersistableLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
if path != "" {
|
||||
return writeUsersFile(path, snapshot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUsers returns a stable, copied snapshot of all users in name order.
|
||||
// PasswordHash and Salt are zeroed in the returned records so callers can
|
||||
// JSON-marshal the result without leaking credentials.
|
||||
func (a *Authenticator) ListUsers() []User {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
out := make([]User, 0, len(a.users))
|
||||
for _, u := range a.users {
|
||||
c := *u
|
||||
c.PasswordHash = ""
|
||||
c.Salt = ""
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
|
||||
return out
|
||||
}
|
||||
|
||||
// GetSalt returns the per-user salt. If the user does not exist, returns ("", false).
|
||||
// Note: the C++ admin uses an empty salt — that is still considered "found"
|
||||
// and the empty string is returned with ok=true.
|
||||
@@ -135,6 +243,134 @@ func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, ro
|
||||
return token, u.Role, nil
|
||||
}
|
||||
|
||||
// usersFileEntry is the on-disk shape of one record in users.json. Field
|
||||
// names match the C++ WebService SaveUsers output exactly so the two
|
||||
// servers can share the file.
|
||||
type usersFileEntry struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Salt string `json:"salt"`
|
||||
Role string `json:"role"`
|
||||
AllowedGroups []string `json:"allowed_groups"`
|
||||
}
|
||||
|
||||
type usersFile struct {
|
||||
Users []usersFileEntry `json:"users"`
|
||||
}
|
||||
|
||||
// SetUsersFile points the authenticator at a JSON file used to persist
|
||||
// non-admin accounts and loads any existing entries. Calling it again with
|
||||
// a different path is allowed but the previous file is not re-read on a
|
||||
// nil-arg call — initialize once at startup. Missing files are not an
|
||||
// error; they're treated as an empty user table.
|
||||
func (a *Authenticator) SetUsersFile(path string) error {
|
||||
a.mu.Lock()
|
||||
a.usersFile = path
|
||||
a.mu.Unlock()
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var f usersFile
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return err
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, e := range f.Users {
|
||||
// Skip admin: it's always sourced from env/master password and
|
||||
// already injected via AddAdminFromPlainPassword.
|
||||
if e.Username == "" || e.Username == "admin" {
|
||||
continue
|
||||
}
|
||||
if e.PasswordHash == "" {
|
||||
continue
|
||||
}
|
||||
role := e.Role
|
||||
if role == "" {
|
||||
role = "viewer"
|
||||
}
|
||||
a.users[e.Username] = &User{
|
||||
Username: e.Username,
|
||||
PasswordHash: e.PasswordHash,
|
||||
Salt: e.Salt,
|
||||
Role: role,
|
||||
AllowedGroups: append([]string(nil), e.AllowedGroups...),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// snapshotPersistableLocked builds the on-disk slice while a.mu is held by
|
||||
// the caller. Admin records are excluded — they live in env, not in the file.
|
||||
func (a *Authenticator) snapshotPersistableLocked() []usersFileEntry {
|
||||
out := make([]usersFileEntry, 0, len(a.users))
|
||||
for _, u := range a.users {
|
||||
if u.Username == "admin" {
|
||||
continue
|
||||
}
|
||||
groups := append([]string{}, u.AllowedGroups...)
|
||||
out = append(out, usersFileEntry{
|
||||
Username: u.Username,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Salt: u.Salt,
|
||||
Role: u.Role,
|
||||
AllowedGroups: groups,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
|
||||
return out
|
||||
}
|
||||
|
||||
// writeUsersFile writes the user list atomically: encode to a temp file in
|
||||
// the same directory, fsync, rename — so a crash mid-write can't leave the
|
||||
// service starting up with a half-written users.json next time.
|
||||
func writeUsersFile(path string, entries []usersFileEntry) error {
|
||||
data, err := json.MarshalIndent(usersFile{Users: entries}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "users-*.json.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateToken returns the session for a token or ErrInvalidToken. Expired
|
||||
// tokens are removed lazily as they are looked up.
|
||||
func (a *Authenticator) ValidateToken(token string) (*Session, error) {
|
||||
|
||||
Reference in New Issue
Block a user