Feature: filter device visibility by allowed groups for web user

This commit is contained in:
yuanyuanxiang
2026-05-02 00:30:08 +02:00
parent 56419f8ecb
commit fd3838a151
3 changed files with 207 additions and 16 deletions

View File

@@ -997,10 +997,14 @@ inline std::string GetWebPageHTML() {
<h4>Create New User</h4> <h4>Create New User</h4>
<input type="text" id="new-username" placeholder="Username" autocomplete="off"> <input type="text" id="new-username" placeholder="Username" autocomplete="off">
<input type="password" id="new-password" placeholder="Password" autocomplete="new-password"> <input type="password" id="new-password" placeholder="Password" autocomplete="new-password">
<select id="new-role"> <select id="new-role" onchange="onRoleChange()">
<option value="viewer">Viewer (read-only)</option> <option value="viewer">Viewer (read-only)</option>
<option value="admin">Admin (full access)</option> <option value="admin">Admin (full access)</option>
</select> </select>
<div class="groups-section" id="groups-section">
<label style="font-size:13px;color:#aaa;display:block;margin:8px 0 4px;">Allowed Groups:</label>
<div id="groups-checkboxes" style="max-height:120px;overflow-y:auto;background:rgba(0,0,0,0.2);border-radius:6px;padding:6px 8px;"></div>
</div>
<button onclick="createUser()">Create User</button> <button onclick="createUser()">Create User</button>
</div> </div>
<div class="user-list"> <div class="user-list">
@@ -1286,6 +1290,11 @@ inline std::string GetWebPageHTML() {
renderUsersList(msg.users); renderUsersList(msg.users);
} }
break; break;
case 'groups':
if (msg.ok) {
renderGroupsCheckboxes(msg.groups);
}
break;
} }
} }
)HTML"; )HTML";
@@ -1661,7 +1670,35 @@ inline std::string GetWebPageHTML() {
function openUsersModal() { function openUsersModal() {
document.getElementById('users-modal').classList.add('active'); document.getElementById('users-modal').classList.add('active');
document.getElementById('user-msg').innerHTML = ''; document.getElementById('user-msg').innerHTML = '';
document.getElementById('new-role').value = 'viewer'; // Reset to default
onRoleChange(); // Update groups section visibility
listUsers(); listUsers();
getGroups();
}
function getGroups() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'get_groups', token }));
}
}
function renderGroupsCheckboxes(groups) {
const container = document.getElementById('groups-checkboxes');
if (!groups || groups.length === 0) {
container.innerHTML = '<span style="color:#666;font-size:12px;">No groups available</span>';
return;
}
container.innerHTML = groups.map(g =>
'<label style="display:flex;align-items:center;padding:3px 0;cursor:pointer;white-space:nowrap;">' +
'<input type="checkbox" value="' + escapeHtml(g) + '" style="margin:0 6px 0 0;flex-shrink:0;width:14px;height:14px;">' +
escapeHtml(g) + '</label>'
).join('');
}
function onRoleChange() {
const role = document.getElementById('new-role').value;
const groupsSection = document.getElementById('groups-section');
groupsSection.style.display = (role === 'admin') ? 'none' : 'block';
} }
function closeUsersModal() { function closeUsersModal() {
@@ -1685,8 +1722,12 @@ inline std::string GetWebPageHTML() {
return; return;
} }
// Collect selected groups
const checkboxes = document.querySelectorAll('#groups-checkboxes input[type="checkbox"]:checked');
const allowed_groups = Array.from(checkboxes).map(cb => cb.value);
if (ws && ws.readyState === WebSocket.OPEN && token) { if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'create_user', token, username, password, role })); ws.send(JSON.stringify({ cmd: 'create_user', token, username, password, role, allowed_groups }));
} }
} }
@@ -1712,10 +1753,14 @@ inline std::string GetWebPageHTML() {
container.innerHTML = users.map(u => { container.innerHTML = users.map(u => {
const isAdmin = u.role === 'admin'; const isAdmin = u.role === 'admin';
const canDelete = u.username !== 'admin'; // Cannot delete built-in admin const canDelete = u.username !== 'admin'; // Cannot delete built-in admin
const groups = u.allowed_groups || [];
const groupsText = u.username === 'admin' ? '(all)' :
(groups.length > 0 ? groups.join(', ') : '(none)');
return '<div class="user-item">' + return '<div class="user-item">' +
'<div class="user-info">' + '<div class="user-info">' +
'<div class="username">' + escapeHtml(u.username) + '</div>' + '<div class="username">' + escapeHtml(u.username) + '</div>' +
'<div class="role ' + (isAdmin ? 'admin' : '') + '">' + u.role + '</div>' + '<div class="role ' + (isAdmin ? 'admin' : '') + '">' + u.role + '</div>' +
'<div class="groups" style="font-size:11px;color:#888;margin-top:2px;">Groups: ' + escapeHtml(groupsText) + '</div>' +
'</div>' + '</div>' +
(canDelete ? '<button class="delete-btn" onclick="deleteUser(\'' + escapeHtml(u.username) + '\')">Delete</button>' : '') + (canDelete ? '<button class="delete-btn" onclick="deleteUser(\'' + escapeHtml(u.username) + '\')">Delete</button>' : '') +
'</div>'; '</div>';

View File

@@ -11,6 +11,7 @@
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <shlobj.h> #include <shlobj.h>
#include <set>
// Algorithm constants (same as ScreenSpyDlg.cpp) // Algorithm constants (same as ScreenSpyDlg.cpp)
#define ALGORITHM_H264 2 #define ALGORITHM_H264 2
@@ -363,6 +364,8 @@ void CWebService::ServerThread(int port) {
HandleDeleteUser(ws_ptr, msg); HandleDeleteUser(ws_ptr, msg);
} else if (cmd == "list_users") { } else if (cmd == "list_users") {
HandleListUsers(ws_ptr, token); HandleListUsers(ws_ptr, token);
} else if (cmd == "get_groups") {
HandleGetGroups(ws_ptr, token);
} }
} }
}); });
@@ -566,7 +569,7 @@ void CWebService::HandleGetDevices(void* ws_ptr, const std::string& token) {
return; return;
} }
SendText(ws_ptr, BuildDeviceListJson()); SendText(ws_ptr, BuildDeviceListJson(username));
} }
void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id) { void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id) {
@@ -588,6 +591,32 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
return; return;
} }
// Check group permission (admin can access all devices)
if (username != "admin") {
std::string deviceGroup = ctx->GetGroupName();
if (deviceGroup.empty()) deviceGroup = "default";
bool hasAccess = false;
{
std::lock_guard<std::mutex> lock(m_UsersMutex);
for (const auto& u : m_Users) {
if (u.username == username) {
for (const auto& g : u.allowed_groups) {
if (g == deviceGroup) {
hasAccess = true;
break;
}
}
break;
}
}
}
if (!hasAccess) {
SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Permission denied"));
return;
}
}
// Check max clients per device // Check max clients per device
int current_count = GetWebClientCount(device_id); int current_count = GetWebClientCount(device_id);
if (current_count >= m_nMaxClientsPerDevice) { if (current_count >= m_nMaxClientsPerDevice) {
@@ -954,12 +983,23 @@ void CWebService::HandleCreateUser(void* ws_ptr, const std::string& msg) {
std::string newPassword = root.get("password", "").asString(); std::string newPassword = root.get("password", "").asString();
std::string newRole = root.get("role", "viewer").asString(); std::string newRole = root.get("role", "viewer").asString();
// Parse allowed_groups array
std::vector<std::string> allowedGroups;
const Json::Value& groups = root["allowed_groups"];
if (groups.isArray()) {
for (const auto& g : groups) {
if (g.isString() && !g.asString().empty()) {
allowedGroups.push_back(g.asString());
}
}
}
if (newUsername.empty() || newPassword.empty()) { if (newUsername.empty() || newPassword.empty()) {
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Username and password required")); SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Username and password required"));
return; return;
} }
if (CreateUser(newUsername, newPassword, newRole)) { if (CreateUser(newUsername, newPassword, newRole, allowedGroups)) {
SendText(ws_ptr, BuildJsonResponse("create_user_result", true)); SendText(ws_ptr, BuildJsonResponse("create_user_result", true));
} else { } else {
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Failed to create user (may already exist)")); SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Failed to create user (may already exist)"));
@@ -1009,18 +1049,27 @@ void CWebService::HandleListUsers(void* ws_ptr, const std::string& token) {
return; return;
} }
auto users = ListUsers();
Json::Value res; Json::Value res;
res["cmd"] = "list_users_result"; res["cmd"] = "list_users_result";
res["ok"] = true; res["ok"] = true;
Json::Value usersArray(Json::arrayValue); Json::Value usersArray(Json::arrayValue);
for (const auto& u : users) { {
Json::Value user; std::lock_guard<std::mutex> lock(m_UsersMutex);
user["username"] = u.first; for (const auto& u : m_Users) {
user["role"] = u.second; Json::Value user;
usersArray.append(user); user["username"] = u.username;
user["role"] = u.role;
// Include allowed_groups
Json::Value groups(Json::arrayValue);
for (const auto& g : u.allowed_groups) {
groups.append(g);
}
user["allowed_groups"] = groups;
usersArray.append(user);
}
} }
res["users"] = usersArray; res["users"] = usersArray;
@@ -1030,6 +1079,48 @@ void CWebService::HandleListUsers(void* ws_ptr, const std::string& token) {
SendText(ws_ptr, json); SendText(ws_ptr, json);
} }
void CWebService::HandleGetGroups(void* ws_ptr, const std::string& token) {
std::string username, role;
if (!ValidateToken(token, username, role)) {
SendText(ws_ptr, BuildJsonResponse("groups", false, "Invalid token"));
return;
}
// Only admin can get groups list (for user management)
if (role != "admin") {
SendText(ws_ptr, BuildJsonResponse("groups", false, "Permission denied"));
return;
}
// Collect all unique groups from online devices
std::set<std::string> groups;
groups.insert("default"); // Always include default group
if (m_pParentDlg) {
EnterCriticalSection(&m_pParentDlg->m_cs);
for (context* ctx : m_pParentDlg->m_HostList) {
if (!ctx || !ctx->IsLogin()) continue;
std::string g = ctx->GetGroupName();
groups.insert(g.empty() ? "default" : g);
}
LeaveCriticalSection(&m_pParentDlg->m_cs);
}
// Build response
Json::Value res;
res["cmd"] = "groups";
res["ok"] = true;
res["groups"] = Json::Value(Json::arrayValue);
for (const auto& g : groups) {
res["groups"].append(g);
}
Json::StreamWriterBuilder builder;
builder["indentation"] = "";
std::string json = Json::writeString(builder, res);
SendText(ws_ptr, json);
}
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////
// Token Management (delegated to WebServiceAuth module) // Token Management (delegated to WebServiceAuth module)
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////
@@ -1170,6 +1261,14 @@ void CWebService::LoadUsers() {
user.salt = u.get("salt", "").asString(); user.salt = u.get("salt", "").asString();
user.role = u.get("role", "viewer").asString(); user.role = u.get("role", "viewer").asString();
// Load allowed_groups
const Json::Value& groups = u["allowed_groups"];
if (groups.isArray()) {
for (const auto& g : groups) {
user.allowed_groups.push_back(g.asString());
}
}
if (!user.password_hash.empty()) { if (!user.password_hash.empty()) {
m_Users.push_back(user); m_Users.push_back(user);
loaded++; loaded++;
@@ -1197,6 +1296,14 @@ void CWebService::SaveUsers() {
user["password_hash"] = u.password_hash; user["password_hash"] = u.password_hash;
user["salt"] = u.salt; user["salt"] = u.salt;
user["role"] = u.role; user["role"] = u.role;
// Save allowed_groups
Json::Value groups(Json::arrayValue);
for (const auto& g : u.allowed_groups) {
groups.append(g);
}
user["allowed_groups"] = groups;
users.append(user); users.append(user);
} }
@@ -1217,7 +1324,8 @@ void CWebService::SaveUsers() {
Mprintf("[WebService] Saved %d users to users.json\n", (int)users.size()); Mprintf("[WebService] Saved %d users to users.json\n", (int)users.size());
} }
bool CWebService::CreateUser(const std::string& username, const std::string& password, const std::string& role) { bool CWebService::CreateUser(const std::string& username, const std::string& password, const std::string& role,
const std::vector<std::string>& allowed_groups) {
if (username.empty() || password.empty()) return false; if (username.empty() || password.empty()) return false;
if (username == "admin") return false; // Cannot create user named "admin" if (username == "admin") return false; // Cannot create user named "admin"
if (role != "admin" && role != "viewer") return false; if (role != "admin" && role != "viewer") return false;
@@ -1236,9 +1344,11 @@ bool CWebService::CreateUser(const std::string& username, const std::string& pas
user.salt = GenerateSalt(); user.salt = GenerateSalt();
user.password_hash = WSAuth::ComputeSHA256(password + user.salt); user.password_hash = WSAuth::ComputeSHA256(password + user.salt);
user.role = role; user.role = role;
user.allowed_groups = allowed_groups;
m_Users.push_back(user); m_Users.push_back(user);
Mprintf("[WebService] Created user: %s (role: %s)\n", username.c_str(), role.c_str()); Mprintf("[WebService] Created user: %s (role: %s, groups: %d)\n",
username.c_str(), role.c_str(), (int)allowed_groups.size());
} }
// Save to file (outside lock scope since SaveUsers acquires its own lock) // Save to file (outside lock scope since SaveUsers acquires its own lock)
@@ -1295,17 +1405,47 @@ std::string CWebService::BuildJsonResponse(const std::string& cmd, bool ok, cons
return Json::writeString(builder, res); return Json::writeString(builder, res);
} }
std::string CWebService::BuildDeviceListJson() { std::string CWebService::BuildDeviceListJson(const std::string& username) {
Json::Value res; Json::Value res;
res["cmd"] = "device_list"; res["cmd"] = "device_list";
res["devices"] = Json::Value(Json::arrayValue); res["devices"] = Json::Value(Json::arrayValue);
// Get user's allowed groups for filtering (skip for admin or empty username)
std::vector<std::string> allowedGroups;
bool filterByGroup = false;
if (!username.empty() && username != "admin") {
std::lock_guard<std::mutex> lock(m_UsersMutex);
for (const auto& u : m_Users) {
if (u.username == username) {
allowedGroups = u.allowed_groups;
filterByGroup = true;
break;
}
}
}
if (m_pParentDlg) { if (m_pParentDlg) {
// Access device list with lock // Access device list with lock
EnterCriticalSection(&m_pParentDlg->m_cs); EnterCriticalSection(&m_pParentDlg->m_cs);
for (context* ctx : m_pParentDlg->m_HostList) { for (context* ctx : m_pParentDlg->m_HostList) {
if (!ctx || !ctx->IsLogin()) continue; if (!ctx || !ctx->IsLogin()) continue;
// Get device group (empty = "default")
std::string deviceGroup = ctx->GetGroupName();
if (deviceGroup.empty()) deviceGroup = "default";
// Filter by allowed groups if user is not admin
if (filterByGroup) {
bool allowed = false;
for (const auto& g : allowedGroups) {
if (g == deviceGroup) {
allowed = true;
break;
}
}
if (!allowed) continue; // Skip device not in allowed groups
}
Json::Value device; Json::Value device;
// Use string for ID to avoid JavaScript number precision loss // Use string for ID to avoid JavaScript number precision loss
device["id"] = std::to_string(ctx->GetClientID()); device["id"] = std::to_string(ctx->GetClientID());
@@ -1332,6 +1472,9 @@ std::string CWebService::BuildDeviceListJson() {
device["activeWindow"] = AnsiToUtf8(activeWindow); device["activeWindow"] = AnsiToUtf8(activeWindow);
device["online"] = true; device["online"] = true;
// Add device group to response
device["group"] = deviceGroup;
// Get screen info from client's reported resolution // Get screen info from client's reported resolution
// Format: "n:MxN" where n=monitor count, M=width, N=height // Format: "n:MxN" where n=monitor count, M=width, N=height
CString resolution = ctx->GetAdditionalData(RES_RESOLUTION); CString resolution = ctx->GetAdditionalData(RES_RESOLUTION);

View File

@@ -43,6 +43,7 @@ struct WebUser {
std::string password_hash; // SHA256(password + salt) std::string password_hash; // SHA256(password + salt)
std::string salt; std::string salt;
std::string role; // "admin" | "viewer" std::string role; // "admin" | "viewer"
std::vector<std::string> allowed_groups; // Groups this user can view (empty = no access, admin = all)
}; };
// Device info for web clients // Device info for web clients
@@ -79,7 +80,8 @@ public:
void SetAdminPassword(const std::string& password); void SetAdminPassword(const std::string& password);
// User management // User management
bool CreateUser(const std::string& username, const std::string& password, const std::string& role); bool CreateUser(const std::string& username, const std::string& password, const std::string& role,
const std::vector<std::string>& allowed_groups = {});
bool DeleteUser(const std::string& username); bool DeleteUser(const std::string& username);
std::vector<std::pair<std::string, std::string>> ListUsers(); // Returns [(username, role), ...] std::vector<std::pair<std::string, std::string>> ListUsers(); // Returns [(username, role), ...]
@@ -144,7 +146,7 @@ private:
// JSON helpers // JSON helpers
std::string BuildJsonResponse(const std::string& cmd, bool ok, const std::string& msg = ""); std::string BuildJsonResponse(const std::string& cmd, bool ok, const std::string& msg = "");
std::string BuildDeviceListJson(); std::string BuildDeviceListJson(const std::string& username = "");
// Password verification // Password verification
bool VerifyPassword(const std::string& input, const WebUser& user); bool VerifyPassword(const std::string& input, const WebUser& user);
@@ -157,6 +159,7 @@ private:
void HandleCreateUser(void* ws_ptr, const std::string& msg); void HandleCreateUser(void* ws_ptr, const std::string& msg);
void HandleDeleteUser(void* ws_ptr, const std::string& msg); void HandleDeleteUser(void* ws_ptr, const std::string& msg);
void HandleListUsers(void* ws_ptr, const std::string& token); void HandleListUsers(void* ws_ptr, const std::string& token);
void HandleGetGroups(void* ws_ptr, const std::string& token);
// Send to WebSocket // Send to WebSocket
void SendText(void* ws_ptr, const std::string& text); void SendText(void* ws_ptr, const std::string& text);