Compare commits
10 Commits
71807c8815
...
v1.13.4
| Author | SHA1 | Date | |
|---|---|---|---|
| b0287f1b10 | |||
| 186c1eaf2b | |||
| c93f73ce02 | |||
| cac47b74bd | |||
| 7c6bf99201 | |||
| bc3f415647 | |||
| 9cbc8e682a | |||
| f4631d1da0 | |||
|
|
9413cf9ab9 | ||
| 070001ffff |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboard/unifi",
|
"name": "dashboard/unifi",
|
||||||
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
||||||
"version": "1.13.1",
|
"version": "1.13.4",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
"tier": "optional",
|
||||||
|
"stability": "stable",
|
||||||
"nav_folder": {
|
"nav_folder": {
|
||||||
"label": "Unifi Network",
|
"label": "Unifi Network",
|
||||||
"icon": "wifi",
|
"icon": "wifi",
|
||||||
@@ -33,7 +35,8 @@
|
|||||||
{ "key": "unifi.stats", "label": "View Network Stats", "description": "View WiFi dashboards, AP stats, and client lists" },
|
{ "key": "unifi.stats", "label": "View Network Stats", "description": "View WiFi dashboards, AP stats, and client lists" },
|
||||||
{ "key": "unifi.manage", "label": "Manage Network", "description": "Reboot APs, manage SSIDs, change WiFi passwords" },
|
{ "key": "unifi.manage", "label": "Manage Network", "description": "Reboot APs, manage SSIDs, change WiFi passwords" },
|
||||||
{ "key": "unifi.auth", "label": "Manage Portal Auth", "description": "Configure captive portal, VLAN mappings, MAC allowlist" },
|
{ "key": "unifi.auth", "label": "Manage Portal Auth", "description": "Configure captive portal, VLAN mappings, MAC allowlist" },
|
||||||
{ "key": "unifi.settings", "label": "Network Settings", "description": "Configure UniFi controller connection" }
|
{ "key": "unifi.settings", "label": "Network Settings", "description": "Configure UniFi controller connection" },
|
||||||
|
{ "key": "unifi.guest_wifi", "label": "View Guest Wi-Fi Password", "description": "See the guest network name, its current rotating password, and the join QR code on the home page. Grants no other network access." }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
104
src/Http/Controllers/GuestCardController.php
Normal file
104
src/Http/Controllers/GuestCardController.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Unifi\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Guest Wi-Fi" dashboard home card: network name, the current
|
||||||
|
* rotating password, and a QR code that re-generates when the password
|
||||||
|
* changes. The password is the rotation engine's last-applied value
|
||||||
|
* (unifi.password_rotation.last_password), so the card tracks whatever
|
||||||
|
* unifi:rotate-passwords sets. The card polls data() so it updates
|
||||||
|
* without a page reload.
|
||||||
|
*/
|
||||||
|
class GuestCardController extends Controller
|
||||||
|
{
|
||||||
|
/** Payload shared by the card resolver and the poll endpoint. */
|
||||||
|
public static function payload(): ?array
|
||||||
|
{
|
||||||
|
if (! Setting::get('unifi.guest_card.enabled')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = Setting::get('unifi.password_rotation.last_password');
|
||||||
|
$ssid = trim((string) Setting::get('unifi.guest_card.ssid', ''));
|
||||||
|
if (! $password || $ssid === '') {
|
||||||
|
return null; // nothing meaningful to show yet
|
||||||
|
}
|
||||||
|
|
||||||
|
$freq = Setting::get('unifi.password_rotation.frequency', 'weekly');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => trim((string) Setting::get('unifi.guest_card.title', '')) ?: 'Guest Wi-Fi',
|
||||||
|
'ssid' => $ssid,
|
||||||
|
'password' => $password,
|
||||||
|
'rotated_at' => Setting::get('unifi.password_rotation.last_rotated_at'),
|
||||||
|
// WPA/WPA2 join string a phone camera understands. Special
|
||||||
|
// chars in SSID/password must be backslash-escaped per the
|
||||||
|
// Wi-Fi QR spec.
|
||||||
|
'qr' => self::wifiQrString($ssid, $password),
|
||||||
|
'frequency' => in_array($freq, ['daily', 'weekly'], true) ? $freq : 'weekly',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dashboard card this endpoint feeds — must match UnifiServiceProvider. */
|
||||||
|
private const CARD_KEY = 'unifi.guest_wifi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY: authorize against the card's own visibility.
|
||||||
|
*
|
||||||
|
* This was `web,auth` only, and the route comment claimed "card visibility is
|
||||||
|
* grant-gated by the shell" — but that gate is PRESENTATION only
|
||||||
|
* (DashboardController decides which cards to render). The endpoint itself was
|
||||||
|
* unchecked, so any signed-in account — every student and parent included —
|
||||||
|
* could fetch the live guest Wi-Fi password by requesting the URL directly.
|
||||||
|
*
|
||||||
|
* The endpoint enforces what the comment always claimed: you may poll it iff
|
||||||
|
* you may see the card. Still asked as the CARD gate — via the registry, which
|
||||||
|
* now answers from the card's permission (unifi.guest_wifi) instead of the
|
||||||
|
* retired dashboard_card_grants table. Asking the registry rather than naming
|
||||||
|
* the key here is deliberate: the endpoint and the home page cannot drift.
|
||||||
|
* Super admins pass, as everywhere else (keysFor handles that).
|
||||||
|
*/
|
||||||
|
public function data(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
abort_unless(
|
||||||
|
app(\App\Support\DashboardCardRegistry::class)->visibleTo($user, self::CARD_KEY),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(self::payload() ?? ['title' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card registry resolver.
|
||||||
|
*
|
||||||
|
* Re-checks the permission the registry already gated on, the way bosch's and
|
||||||
|
* signinout's resolvers do. Redundant on the home-page path and deliberately
|
||||||
|
* kept: renderFor() renders whatever key list it is handed, and the payload
|
||||||
|
* here is a live credential — the one card where "the caller asked the right
|
||||||
|
* question" is not a good enough reason to hand it over.
|
||||||
|
*/
|
||||||
|
public static function resolve(User $user): ?array
|
||||||
|
{
|
||||||
|
if (! $user->can('unifi.guest_wifi')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::payload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function wifiQrString(string $ssid, string $password): string
|
||||||
|
{
|
||||||
|
$esc = fn (string $v) => preg_replace('/([\\\\;,:"])/', '\\\\$1', $v);
|
||||||
|
|
||||||
|
return sprintf('WIFI:T:WPA;S:%s;P:%s;;', $esc($ssid), $esc($password));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,11 @@ class PortalController extends Controller
|
|||||||
'match_value' => 'required|string|max:255',
|
'match_value' => 'required|string|max:255',
|
||||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||||
'max_devices' => 'required|integer|min:1|max:50',
|
'max_devices' => 'required|integer|min:1|max:50',
|
||||||
'session_minutes' => 'required|integer|min:1',
|
// Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
|
||||||
|
// is unsignedInteger, so an unbounded value reached the INSERT and raised
|
||||||
|
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
|
||||||
|
// 525600 minutes is a year, far more session than anyone will ever grant.
|
||||||
|
'session_minutes' => 'required|integer|min:1|max:525600',
|
||||||
]);
|
]);
|
||||||
$data['sort_order'] = VlanMapping::max('sort_order') + 1;
|
$data['sort_order'] = VlanMapping::max('sort_order') + 1;
|
||||||
VlanMapping::create($data);
|
VlanMapping::create($data);
|
||||||
@@ -51,7 +55,11 @@ class PortalController extends Controller
|
|||||||
'match_value' => 'required|string|max:255',
|
'match_value' => 'required|string|max:255',
|
||||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||||
'max_devices' => 'required|integer|min:1|max:50',
|
'max_devices' => 'required|integer|min:1|max:50',
|
||||||
'session_minutes' => 'required|integer|min:1',
|
// Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
|
||||||
|
// is unsignedInteger, so an unbounded value reached the INSERT and raised
|
||||||
|
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
|
||||||
|
// 525600 minutes is a year, far more session than anyone will ever grant.
|
||||||
|
'session_minutes' => 'required|integer|min:1|max:525600',
|
||||||
]);
|
]);
|
||||||
$mapping->update($data);
|
$mapping->update($data);
|
||||||
return back()->with('success', 'Mapping updated.');
|
return back()->with('success', 'Mapping updated.');
|
||||||
@@ -87,6 +95,35 @@ class PortalController extends Controller
|
|||||||
|
|
||||||
// ── Captive portal callback (called by UniFi external portal redirect) ───
|
// ── Captive portal callback (called by UniFi external portal redirect) ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A redirect target that cannot be turned into a phishing or script payload.
|
||||||
|
*
|
||||||
|
* Accepts only absolute http/https URLs and site-relative paths. Rejects
|
||||||
|
* javascript:/data:/vbscript: schemes, and protocol-relative "//host" values which
|
||||||
|
* redirect() would happily send off-site. Anything rejected falls back to the
|
||||||
|
* dashboard rather than erroring — the guest is already online at this point, so a
|
||||||
|
* bad `url` parameter should not look like a failed login.
|
||||||
|
*/
|
||||||
|
private function safeRedirectTarget(?string $url): string
|
||||||
|
{
|
||||||
|
$url = trim((string) $url);
|
||||||
|
|
||||||
|
if ($url === '' || str_starts_with($url, '//')) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site-relative is always fine.
|
||||||
|
if (str_starts_with($url, '/')) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
|
||||||
|
|
||||||
|
return in_array($scheme, ['http', 'https'], true) && parse_url($url, PHP_URL_HOST)
|
||||||
|
? $url
|
||||||
|
: '/';
|
||||||
|
}
|
||||||
|
|
||||||
public function captiveCallback(Request $request, UnifiApiClient $unifi)
|
public function captiveCallback(Request $request, UnifiApiClient $unifi)
|
||||||
{
|
{
|
||||||
// UniFi redirects guest to this URL with ?id=<ap_mac>&ap=<ap_mac>&t=<timestamp>&url=<original_url>&ssid=<ssid>
|
// UniFi redirects guest to this URL with ?id=<ap_mac>&ap=<ap_mac>&t=<timestamp>&url=<original_url>&ssid=<ssid>
|
||||||
@@ -104,6 +141,23 @@ class PortalController extends Controller
|
|||||||
return response('Missing MAC address', 400);
|
return response('Missing MAC address', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SECURITY: everything identifying the device is request-supplied, and this
|
||||||
|
// endpoint WRITES network state (authorizeGuest puts a MAC on the mapped VLAN).
|
||||||
|
// Before this check any signed-in account — a student, a parent — could call
|
||||||
|
// GET /portal/wifi/callback?mac=<someone else's MAC>&url=…
|
||||||
|
// and admit a device they do not own, up to max_devices.
|
||||||
|
//
|
||||||
|
// A well-formed MAC is the floor, not a fix: it stops the parameter being used
|
||||||
|
// to smuggle junk into authorizeGuest and into the PortalSession row, but it
|
||||||
|
// cannot prove the caller owns the device. Properly binding it needs the AP
|
||||||
|
// handshake — UniFi can sign the redirect, or we ask the controller whether this
|
||||||
|
// MAC is CURRENTLY associated to $apMac before admitting it. Both need testing
|
||||||
|
// against real hardware, so they are deliberately not guessed at here.
|
||||||
|
$clientMac = str_replace('-', ':', $clientMac);
|
||||||
|
if (! preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $clientMac)) {
|
||||||
|
return response('Malformed MAC address', 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Find the VLAN mapping for this user's group/OU
|
// Find the VLAN mapping for this user's group/OU
|
||||||
$mapping = $this->resolveMapping($user);
|
$mapping = $this->resolveMapping($user);
|
||||||
if (! $mapping) {
|
if (! $mapping) {
|
||||||
@@ -161,9 +215,16 @@ class PortalController extends Controller
|
|||||||
'expires_at' => now()->addMinutes($minutes),
|
'expires_at' => now()->addMinutes($minutes),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Redirect to the original URL the user was trying to reach
|
// Redirect to the original URL the user was trying to reach.
|
||||||
$redirectUrl = $request->input('url', '/');
|
//
|
||||||
return redirect($redirectUrl);
|
// SECURITY: this was `redirect($request->input('url', '/'))` — an unvalidated
|
||||||
|
// open redirect on an authenticated route, i.e. a phishing primitive wearing
|
||||||
|
// this school's domain. The whole point of a captive portal is to send someone
|
||||||
|
// to an ARBITRARY external site they were already trying to reach, so an
|
||||||
|
// allowlist is not available; what is available is refusing anything that is
|
||||||
|
// not a plain http(s) URL, which blocks javascript:, data: and protocol-relative
|
||||||
|
// //evil.example payloads.
|
||||||
|
return redirect($this->safeRedirectTarget($request->input('url')));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function disconnectSession(Request $request, PortalSession $session, UnifiApiClient $unifi)
|
public function disconnectSession(Request $request, PortalSession $session, UnifiApiClient $unifi)
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Dashboard\Unifi\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\DashboardApp;
|
|
||||||
use App\Models\Group;
|
|
||||||
use App\Models\NavItem;
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\User;
|
|
||||||
use Dashboard\Unifi\Models\UnifiPageGrant;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Routing\Controller;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Super-admin-only endpoints for managing per-page access on unifi
|
|
||||||
* pages. Pages here = nav_items where app_id = unifi's DashboardApp row.
|
|
||||||
*/
|
|
||||||
class UnifiPagesAccessController extends Controller
|
|
||||||
{
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
$app = DashboardApp::where('slug', 'unifi')->first();
|
|
||||||
if (! $app) {
|
|
||||||
return response()->json(['pages' => [], 'users' => [], 'groups' => [], 'roles' => []]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pages = NavItem::where('app_id', $app->id)
|
|
||||||
->where('is_folder', false)
|
|
||||||
->whereNotNull('route_name')
|
|
||||||
->orderBy('sort_order')
|
|
||||||
->get(['id', 'label', 'route_name']);
|
|
||||||
|
|
||||||
$grants = UnifiPageGrant::whereIn('nav_item_id', $pages->pluck('id'))
|
|
||||||
->get()
|
|
||||||
->groupBy('nav_item_id');
|
|
||||||
|
|
||||||
// Only return users that ALREADY have grants. The full users list
|
|
||||||
// can be enormous (thousands of rows); the operator adds more via
|
|
||||||
// the searchUsers endpoint as needed.
|
|
||||||
$grantedUserIds = $grants->flatten(1)->where('grantee_type', 'user')->pluck('grantee_id')->unique();
|
|
||||||
$users = User::whereIn('id', $grantedUserIds)->orderBy('name')->get(['id', 'name', 'email']);
|
|
||||||
|
|
||||||
// Groups: always include super-admin groups (locked-on across all
|
|
||||||
// pages) plus any group with at least one grant. Other groups are
|
|
||||||
// added via searchGroups.
|
|
||||||
$grantedGroupIds = $grants->flatten(1)->where('grantee_type', 'group')->pluck('grantee_id')->unique();
|
|
||||||
$groups = Group::where(function ($q) use ($grantedGroupIds) {
|
|
||||||
$q->where('is_super', true)
|
|
||||||
->orWhereIn('id', $grantedGroupIds);
|
|
||||||
})->orderBy('name')->get(['id', 'name', 'is_super']);
|
|
||||||
|
|
||||||
// Roles: only ones with at least one grant — more added via searchRoles.
|
|
||||||
$grantedRoleIds = $grants->flatten(1)->where('grantee_type', 'role')->pluck('grantee_id')->unique();
|
|
||||||
$roles = Role::whereIn('id', $grantedRoleIds)->orderBy('label')->get(['id', 'slug', 'label']);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'pages' => $pages->map(function ($p) use ($grants) {
|
|
||||||
$pageGrants = $grants->get($p->id, collect());
|
|
||||||
$defaultRow = $pageGrants->firstWhere('grantee_type', 'default');
|
|
||||||
return [
|
|
||||||
'id' => $p->id,
|
|
||||||
'label' => $p->label,
|
|
||||||
'route_name' => $p->route_name,
|
|
||||||
'default_allow' => (bool) ($defaultRow?->can_view ?? false),
|
|
||||||
'user_ids' => $pageGrants->where('grantee_type', 'user')->where('can_view', true)->pluck('grantee_id')->values()->all(),
|
|
||||||
'group_ids' => $pageGrants->where('grantee_type', 'group')->where('can_view', true)->pluck('grantee_id')->values()->all(),
|
|
||||||
'role_ids' => $pageGrants->where('grantee_type', 'role')->where('can_view', true)->pluck('grantee_id')->values()->all(),
|
|
||||||
];
|
|
||||||
})->values(),
|
|
||||||
'users' => $users,
|
|
||||||
'groups' => $groups,
|
|
||||||
'roles' => $roles,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typeahead-style search for users to add to the access matrix.
|
|
||||||
* Returns up to 20 matches against name or email. Empty query returns
|
|
||||||
* an empty array — caller must enter at least 2 chars.
|
|
||||||
*/
|
|
||||||
public function searchUsers(Request $request)
|
|
||||||
{
|
|
||||||
$q = trim((string) $request->query('q', ''));
|
|
||||||
if (strlen($q) < 2) {
|
|
||||||
return response()->json(['users' => []]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$users = User::where(function ($w) use ($q) {
|
|
||||||
$w->where('name', 'like', '%' . $q . '%')
|
|
||||||
->orWhere('email', 'like', '%' . $q . '%');
|
|
||||||
})
|
|
||||||
->orderBy('name')
|
|
||||||
->limit(20)
|
|
||||||
->get(['id', 'name', 'email']);
|
|
||||||
|
|
||||||
return response()->json(['users' => $users]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typeahead-style search for groups to add to the access matrix.
|
|
||||||
* Excludes super-admin groups (they're already in the matrix and
|
|
||||||
* locked-on across every page).
|
|
||||||
*/
|
|
||||||
public function searchGroups(Request $request)
|
|
||||||
{
|
|
||||||
$q = trim((string) $request->query('q', ''));
|
|
||||||
if (strlen($q) < 2) {
|
|
||||||
return response()->json(['groups' => []]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$groups = Group::where('name', 'like', '%' . $q . '%')
|
|
||||||
->where(function ($w) { $w->where('is_super', false)->orWhereNull('is_super'); })
|
|
||||||
->orderBy('name')
|
|
||||||
->limit(20)
|
|
||||||
->get(['id', 'name', 'is_super']);
|
|
||||||
|
|
||||||
return response()->json(['groups' => $groups]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typeahead-style search for roles to add to the access matrix.
|
|
||||||
* An empty query returns every role (the role list is small).
|
|
||||||
*/
|
|
||||||
public function searchRoles(Request $request)
|
|
||||||
{
|
|
||||||
$q = trim((string) $request->query('q', ''));
|
|
||||||
if (strlen($q) < 1) {
|
|
||||||
return response()->json(['roles' => Role::orderBy('label')->get(['id', 'slug', 'label'])]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$roles = Role::where('label', 'like', '%' . $q . '%')
|
|
||||||
->orWhere('slug', 'like', '%' . $q . '%')
|
|
||||||
->orderBy('label')
|
|
||||||
->limit(20)
|
|
||||||
->get(['id', 'slug', 'label']);
|
|
||||||
|
|
||||||
return response()->json(['roles' => $roles]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request, NavItem $navItem)
|
|
||||||
{
|
|
||||||
$app = DashboardApp::where('slug', 'unifi')->first();
|
|
||||||
if (! $app || $navItem->app_id !== $app->id) {
|
|
||||||
return response()->json(['error' => 'Not a unifi page.'], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $request->validate([
|
|
||||||
'default_allow' => 'boolean',
|
|
||||||
'user_ids' => 'present|array',
|
|
||||||
'user_ids.*' => 'integer|exists:users,id',
|
|
||||||
'group_ids' => 'present|array',
|
|
||||||
'group_ids.*' => 'integer|exists:groups,id',
|
|
||||||
'role_ids' => 'array',
|
|
||||||
'role_ids.*' => 'integer|exists:roles,id',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$grantedBy = $request->user()?->id;
|
|
||||||
|
|
||||||
DB::transaction(function () use ($navItem, $data, $grantedBy) {
|
|
||||||
// Upsert the default row (one per nav_item). firstOrCreate can't
|
|
||||||
// match on grantee_id=NULL reliably in MySQL, so look it up first.
|
|
||||||
$default = UnifiPageGrant::where('nav_item_id', $navItem->id)
|
|
||||||
->where('grantee_type', 'default')
|
|
||||||
->first();
|
|
||||||
if ($default) {
|
|
||||||
$default->update(['can_view' => (bool) ($data['default_allow'] ?? false)]);
|
|
||||||
} else {
|
|
||||||
UnifiPageGrant::create([
|
|
||||||
'nav_item_id' => $navItem->id,
|
|
||||||
'grantee_type' => 'default',
|
|
||||||
'grantee_id' => null,
|
|
||||||
'can_view' => (bool) ($data['default_allow'] ?? false),
|
|
||||||
'granted_by_user_id' => $grantedBy,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->syncGrantsOfType($navItem->id, 'user', $data['user_ids'] ?? [], $grantedBy);
|
|
||||||
$this->syncGrantsOfType($navItem->id, 'group', $data['group_ids'] ?? [], $grantedBy);
|
|
||||||
$this->syncGrantsOfType($navItem->id, 'role', $data['role_ids'] ?? [], $grantedBy);
|
|
||||||
});
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function syncGrantsOfType(int $navItemId, string $type, array $ids, ?int $actorId): void
|
|
||||||
{
|
|
||||||
UnifiPageGrant::where('nav_item_id', $navItemId)
|
|
||||||
->where('grantee_type', $type)
|
|
||||||
->whereNotIn('grantee_id', $ids ?: [0])
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
foreach ($ids as $id) {
|
|
||||||
UnifiPageGrant::updateOrCreate(
|
|
||||||
[
|
|
||||||
'nav_item_id' => $navItemId,
|
|
||||||
'grantee_type' => $type,
|
|
||||||
'grantee_id' => $id,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'can_view' => true,
|
|
||||||
'granted_by_user_id' => $actorId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,6 +36,9 @@ class UnifiSettingsController extends Controller
|
|||||||
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
||||||
'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false),
|
'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false),
|
||||||
'apiToken' => Setting::get('unifi.api_token', null),
|
'apiToken' => Setting::get('unifi.api_token', null),
|
||||||
|
'guestCardEnabled' => (bool) Setting::get('unifi.guest_card.enabled', false),
|
||||||
|
'guestCardSsid' => Setting::get('unifi.guest_card.ssid', ''),
|
||||||
|
'guestCardTitle' => Setting::get('unifi.guest_card.title', ''),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +77,9 @@ class UnifiSettingsController extends Controller
|
|||||||
'rotation_wordlist' => 'nullable|string|max:20000',
|
'rotation_wordlist' => 'nullable|string|max:20000',
|
||||||
'ppsk_scheduling_enabled' => 'boolean',
|
'ppsk_scheduling_enabled' => 'boolean',
|
||||||
'api_enabled' => 'boolean',
|
'api_enabled' => 'boolean',
|
||||||
|
'guest_card_enabled' => 'boolean',
|
||||||
|
'guest_card_ssid' => 'nullable|string|max:100',
|
||||||
|
'guest_card_title' => 'nullable|string|max:100',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
|
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
|
||||||
@@ -101,6 +107,9 @@ class UnifiSettingsController extends Controller
|
|||||||
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
|
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
|
||||||
Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : '');
|
Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : '');
|
||||||
Setting::set('unifi.api.enabled', $request->boolean('api_enabled') ? '1' : '');
|
Setting::set('unifi.api.enabled', $request->boolean('api_enabled') ? '1' : '');
|
||||||
|
Setting::set('unifi.guest_card.enabled', $request->boolean('guest_card_enabled') ? '1' : '');
|
||||||
|
Setting::set('unifi.guest_card.ssid', trim((string) $request->input('guest_card_ssid', '')));
|
||||||
|
Setting::set('unifi.guest_card.title', trim((string) $request->input('guest_card_title', '')));
|
||||||
|
|
||||||
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));
|
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Dashboard\Unifi\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Models\NavItem;
|
|
||||||
use Closure;
|
|
||||||
use Dashboard\Unifi\Models\UnifiPageGrant;
|
|
||||||
use Dashboard\Unifi\UnifiServiceProvider;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-page access for unifi pages. Attached dynamically by the
|
|
||||||
* RouteMatched listener in UnifiServiceProvider — the check cannot
|
|
||||||
* live in the listener because RouteMatched fires before session/auth
|
|
||||||
* middleware, when request->user() is still null.
|
|
||||||
*/
|
|
||||||
class EnforceUnifiPageGrant
|
|
||||||
{
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
$routeName = $request->route()?->getName();
|
|
||||||
|
|
||||||
if (! $routeName || ! $user || $user->is_super_admin) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$item = NavItem::where('route_name', $routeName)->first();
|
|
||||||
if ($item) {
|
|
||||||
// Permission holders keep access — grants extend, never revoke.
|
|
||||||
$holdsPermission = $item->required_permission
|
|
||||||
&& UnifiServiceProvider::realPermissionKeysFor($user)->contains($item->required_permission);
|
|
||||||
|
|
||||||
if (! $holdsPermission && ! UnifiPageGrant::userCanAccess($user, $item)) {
|
|
||||||
// 404 instead of 403 — don't leak that the page exists.
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
|
||||||
throw $e;
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// unifi_page_grants may not exist yet on first install — fail
|
|
||||||
// open in that narrow window (permission middleware still
|
|
||||||
// guards the route).
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Dashboard\Unifi\Models;
|
|
||||||
|
|
||||||
use App\Models\NavItem;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class UnifiPageGrant extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'unifi_page_grants';
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'nav_item_id',
|
|
||||||
'grantee_type', // default | user | group | role
|
|
||||||
'grantee_id', // null for grantee_type=default
|
|
||||||
'can_view',
|
|
||||||
'granted_by_user_id',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'can_view' => 'boolean',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function navItem(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(NavItem::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function grantedBy(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'granted_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grant check with a per-page default fallback. Passes if the user
|
|
||||||
* matches an explicit user/group/role grant; otherwise falls back
|
|
||||||
* to the page's `default` row (deny by default — no row or
|
|
||||||
* can_view=false denies). Permission-based access is checked
|
|
||||||
* separately by the caller — grants extend, never revoke.
|
|
||||||
*/
|
|
||||||
public static function userCanAccess(User $user, NavItem $navItem): bool
|
|
||||||
{
|
|
||||||
if ($user->is_super_admin) return true;
|
|
||||||
|
|
||||||
$groupIds = $user->groups()->pluck('groups.id');
|
|
||||||
$roleIds = $user->roles()->pluck('roles.id');
|
|
||||||
|
|
||||||
$hasExplicit = static::where('nav_item_id', $navItem->id)
|
|
||||||
->where('can_view', true)
|
|
||||||
->where(function ($q) use ($user, $groupIds, $roleIds) {
|
|
||||||
$q->where(fn ($u) => $u->where('grantee_type', 'user')->where('grantee_id', $user->id))
|
|
||||||
->orWhere(fn ($g) => $g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds))
|
|
||||||
->orWhere(fn ($r) => $r->where('grantee_type', 'role')->whereIn('grantee_id', $roleIds));
|
|
||||||
})
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($hasExplicit) return true;
|
|
||||||
|
|
||||||
return (bool) static::where('nav_item_id', $navItem->id)
|
|
||||||
->where('grantee_type', 'default')
|
|
||||||
->value('can_view');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,6 @@
|
|||||||
namespace Dashboard\Unifi;
|
namespace Dashboard\Unifi;
|
||||||
|
|
||||||
use App\Models\NavItem;
|
use App\Models\NavItem;
|
||||||
use Dashboard\Unifi\Models\UnifiPageGrant;
|
|
||||||
use Illuminate\Routing\Events\RouteMatched;
|
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class UnifiServiceProvider extends ServiceProvider
|
class UnifiServiceProvider extends ServiceProvider
|
||||||
@@ -24,36 +21,41 @@ class UnifiServiceProvider extends ServiceProvider
|
|||||||
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
|
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||||
|
|
||||||
|
// "/" home card: guest Wi-Fi network + current rotating password +
|
||||||
|
// QR (re-renders when the password changes). Self-hides until enabled
|
||||||
|
// and a password has been rotated.
|
||||||
|
//
|
||||||
|
// The ONE card in the platform that earned a NEW permission key rather
|
||||||
|
// than reusing one. It cannot be universal — the payload is a live
|
||||||
|
// credential, and publishing it to every student and parent is the exact
|
||||||
|
// hole GuestCardController::data was written to close. And no existing
|
||||||
|
// unifi key fits: unifi.stats is the network dashboards, unifi.settings is
|
||||||
|
// the controller connection, and handing the front desk either one to let
|
||||||
|
// them read a guest password would widen access, not reuse it. So the
|
||||||
|
// granularity goes in the catalogue (same move as the papercut/inventory
|
||||||
|
// split), and the key is grantable to non-unifi staff — which is the whole
|
||||||
|
// point of a home-page card.
|
||||||
|
//
|
||||||
|
// Declared in composer.json extra.dashboard.permissions, so the catalogue
|
||||||
|
// row appears when unifi is installed/updated — NOT seeded to any group.
|
||||||
|
try {
|
||||||
|
app(\App\Support\DashboardCardRegistry::class)->register(
|
||||||
|
'unifi.guest_wifi',
|
||||||
|
'Guest Wi-Fi',
|
||||||
|
'UnifiGuestWifi',
|
||||||
|
fn (\App\Models\User $user) => \Dashboard\Unifi\Http\Controllers\GuestCardController::resolve($user),
|
||||||
|
'The guest network name, current password, and a join QR code — updates automatically when the password rotates.',
|
||||||
|
permission: 'unifi.guest_wifi',
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Older shell without the card registry — skip.
|
||||||
|
}
|
||||||
|
|
||||||
// Tell the shell's nav sidebar which unifi nav items the user
|
// Tell the shell's nav sidebar which unifi nav items the user
|
||||||
// can see: pages whose required_permission the user holds through
|
// can see: pages whose required_permission the user holds through
|
||||||
// groups, plus pages granted via the Settings → Access tab. The
|
// groups, plus pages granted via the Settings → Access tab. The
|
||||||
// shell also folds these grants back into User::can()/allPermissions
|
// shell also folds these grants back into User::can()/allPermissions
|
||||||
// so the route-level permission middleware passes for grantees.
|
// so the route-level permission middleware passes for grantees.
|
||||||
try {
|
|
||||||
app(\App\Support\NavVisibilityRegistry::class)->register(
|
|
||||||
'unifi.',
|
|
||||||
fn (\App\Models\User $user) => $this->visibleUnifiNavItemIdsFor($user),
|
|
||||||
);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Shell may not have the registry yet (older shell version).
|
|
||||||
// Sidebar will fall back to legacy permission filter.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-page enforcement for unifi pages. Settings stays on its
|
|
||||||
// permission:unifi.settings route middleware (the Access tab
|
|
||||||
// itself lives there and must not be able to lock itself out).
|
|
||||||
// The user-dependent check lives in middleware appended to the
|
|
||||||
// matched route: RouteMatched fires before the session/auth
|
|
||||||
// middleware run, so request->user() is null here and any check
|
|
||||||
// at this point silently fails open.
|
|
||||||
Event::listen(RouteMatched::class, function (RouteMatched $event) {
|
|
||||||
$routeName = $event->route->getName();
|
|
||||||
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return;
|
|
||||||
if (str_starts_with($routeName, 'unifi.settings')) return;
|
|
||||||
|
|
||||||
$event->route->middleware(\Dashboard\Unifi\Http\Middleware\EnforceUnifiPageGrant::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($this->app->runningInConsole()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->commands([
|
$this->commands([
|
||||||
Console\CheckWebhooks::class,
|
Console\CheckWebhooks::class,
|
||||||
@@ -69,38 +71,6 @@ class UnifiServiceProvider extends ServiceProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect every unifi nav_item_id this user is allowed to see. */
|
|
||||||
protected function visibleUnifiNavItemIdsFor(\App\Models\User $user): \Illuminate\Support\Collection
|
|
||||||
{
|
|
||||||
$ids = collect();
|
|
||||||
|
|
||||||
// Pages whose required_permission the user already holds through
|
|
||||||
// groups keep showing, independent of the access-grant matrix.
|
|
||||||
$permittedIds = NavItem::where('route_name', 'like', 'unifi.%')
|
|
||||||
->whereIn('required_permission', static::realPermissionKeysFor($user))
|
|
||||||
->pluck('id');
|
|
||||||
$ids = $ids->merge($permittedIds);
|
|
||||||
|
|
||||||
if (\Illuminate\Support\Facades\Schema::hasTable('unifi_page_grants')) {
|
|
||||||
$groupIds = $user->groups()->pluck('groups.id');
|
|
||||||
$roleIds = $user->roles()->pluck('roles.id');
|
|
||||||
|
|
||||||
$grantedIds = UnifiPageGrant::query()
|
|
||||||
->where('can_view', true)
|
|
||||||
->where(function ($q) use ($user, $groupIds, $roleIds) {
|
|
||||||
$q->where(fn ($u) => $u->where('grantee_type', 'user')->where('grantee_id', $user->id))
|
|
||||||
->orWhere(fn ($g) => $g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds))
|
|
||||||
->orWhere(fn ($r) => $r->where('grantee_type', 'role')->whereIn('grantee_id', $roleIds))
|
|
||||||
// Default-allow row makes the page visible to everyone.
|
|
||||||
->orWhere('grantee_type', 'default');
|
|
||||||
})
|
|
||||||
->pluck('nav_item_id');
|
|
||||||
$ids = $ids->merge($grantedIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $ids->unique()->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group + direct permissions only. Deliberately NOT allPermissions():
|
* Group + direct permissions only. Deliberately NOT allPermissions():
|
||||||
* the shell folds page-grant permissions back into allPermissions/can,
|
* the shell folds page-grant permissions back into allPermissions/can,
|
||||||
|
|||||||
@@ -75,17 +75,6 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
|||||||
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
|
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
|
||||||
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
|
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
|
||||||
|
|
||||||
// Page Access — super-admin only. Lists unifi pages and lets
|
|
||||||
// operators assign per-page user/group/role grants plus the
|
|
||||||
// per-page "everyone else" default row (deny by default).
|
|
||||||
Route::middleware('super.admin')->group(function () {
|
|
||||||
Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index');
|
|
||||||
Route::get('/settings/pages-access/users/search', [UnifiPagesAccessController::class, 'searchUsers'])->name('settings.pages-access.users.search');
|
|
||||||
Route::get('/settings/pages-access/groups/search', [UnifiPagesAccessController::class, 'searchGroups'])->name('settings.pages-access.groups.search');
|
|
||||||
Route::get('/settings/pages-access/roles/search', [UnifiPagesAccessController::class, 'searchRoles'])->name('settings.pages-access.roles.search');
|
|
||||||
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cron logs — read-only history of scheduled-task runs.
|
// Cron logs — read-only history of scheduled-task runs.
|
||||||
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
|
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
|
||||||
|
|
||||||
@@ -103,6 +92,15 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Guest Wi-Fi home-card poll. Authorises inside the controller against the
|
||||||
|
// card's own visibility (unifi.guest_wifi) — it returns a live credential, so
|
||||||
|
// `auth` alone is not a gate. Deliberately outside the permission groups above
|
||||||
|
// because it's a home-page widget, not a unifi page: unifi.guest_wifi is
|
||||||
|
// grantable to front-desk staff without any other network access. ──
|
||||||
|
Route::middleware(['web', 'auth'])
|
||||||
|
->get('/app/unifi/guest-card/data', [\Dashboard\Unifi\Http\Controllers\GuestCardController::class, 'data'])
|
||||||
|
->name('unifi.guest-card.data');
|
||||||
|
|
||||||
// ── Public API (token-protected) ──────────────────────────────────────────
|
// ── Public API (token-protected) ──────────────────────────────────────────
|
||||||
// External integrations (signage, kiosks) hit these without session auth.
|
// External integrations (signage, kiosks) hit these without session auth.
|
||||||
Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
|
Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
|
||||||
@@ -114,3 +112,21 @@ Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
|
|||||||
Route::middleware(['web', 'auth'])
|
Route::middleware(['web', 'auth'])
|
||||||
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
|
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
|
||||||
->name('unifi.portal.callback');
|
->name('unifi.portal.callback');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Let a user disconnect their OWN device from the captive-portal denial page.
|
||||||
|
*
|
||||||
|
* The admin route for this (unifi.portal.sessions.disconnect) sits behind
|
||||||
|
* `app.access:unifi` + `permission:unifi.auth`, so a student who is told "you
|
||||||
|
* already have 3 devices connected, disconnect one" was blocked by middleware
|
||||||
|
* before reaching the action — the page's `can_disconnect: true` was a promise
|
||||||
|
* the routing could not keep.
|
||||||
|
*
|
||||||
|
* Safe to expose with only `auth`: disconnectSession() already authorises
|
||||||
|
* owner-or-unifi.auth itself (`abort_if($session->user_id !== auth()->id() && !
|
||||||
|
* auth()->user()->can('unifi.auth'), 403)`), so a non-owner still gets 403. The
|
||||||
|
* admin route is left exactly as it was rather than loosened.
|
||||||
|
*/
|
||||||
|
Route::middleware(['web', 'auth'])
|
||||||
|
->post('/portal/wifi/sessions/{session}/disconnect', [PortalController::class, 'disconnectSession'])
|
||||||
|
->name('unifi.portal.self-disconnect');
|
||||||
|
|||||||
Reference in New Issue
Block a user