VlanGroup::orderBy('sort_order')->get(), 'vlanMappings' => VlanMapping::orderBy('sort_order')->get(), 'knownMacs' => KnownMac::orderBy('device_name')->paginate(50), 'activeSessions' => PortalSession::where('is_active', true) ->with('user:id,name,email') ->latest('authorized_at') ->get(), ]); } public function storeMapping(Request $request) { $data = $request->validate([ 'group_name' => 'required|string|max:255', 'match_type' => 'required|in:ou,group,email_domain,role', 'match_value' => 'required|string|max:255', 'vlan_id' => 'required|integer|min:1|max:4094', 'max_devices' => 'required|integer|min:1|max:50', // 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; VlanMapping::create($data); return back()->with('success', 'VLAN mapping created.'); } public function updateMapping(Request $request, VlanMapping $mapping) { $data = $request->validate([ 'group_name' => 'required|string|max:255', 'match_type' => 'required|in:ou,group,email_domain,role', 'match_value' => 'required|string|max:255', 'vlan_id' => 'required|integer|min:1|max:4094', 'max_devices' => 'required|integer|min:1|max:50', // 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); return back()->with('success', 'Mapping updated.'); } public function destroyMapping(VlanMapping $mapping) { $mapping->delete(); return back()->with('success', 'Mapping deleted.'); } // ── Known MACs ──────────────────────────────────────────────────────────── public function storeMac(Request $request) { $data = $request->validate([ 'mac_address' => 'required|string|regex:/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/|unique:unifi_known_macs,mac_address', 'device_name' => 'nullable|string|max:255', 'device_type' => 'nullable|string|max:100', 'owner' => 'nullable|string|max:255', 'vlan_id' => 'required|integer|min:1|max:4094', 'notes' => 'nullable|string|max:1000', ]); KnownMac::create($data); return back()->with('success', 'MAC address added.'); } public function destroyMac(KnownMac $mac) { $mac->delete(); return back()->with('success', 'MAC address removed.'); } // ── 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) { // UniFi redirects guest to this URL with ?id=&ap=&t=&url=&ssid= // At this point the user has already authenticated via Google OAuth (handled by the shell's auth system) $user = $request->user(); if (! $user) { return redirect()->route('login'); } $apMac = (string) $request->input('ap', ''); $ssid = (string) $request->input('ssid', ''); $device = app(\Dashboard\Unifi\Services\DeviceResolver::class)->resolve($request, $apMac); if (! $device->ok) { return Inertia::render('Unifi/PortalDenied', [ 'reason' => match ($device->reason) { 'ap_mismatch' => 'That device is not connected to this access point. Reconnect and try again.', 'proxy_untrusted' => 'Wi-Fi sign-in is misconfigured and cannot identify your device. Please tell IT.', default => 'Still connecting — wait a moment and try again.', }, 'resolveFailure' => $device->reason, ]); } $clientMac = $device->mac; $isRandomized = $device->isRandomized; // Find the VLAN mapping for this user's group/OU $mapping = $this->resolveMapping($user); if (! $mapping) { return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']); } /* * Presence is NOT the question — a live authorization is. * * This used to walk the user's sessions and mark any device UniFi did not * currently see as stale, freeing its slot. But UniFi holds an * authorization for its whole window regardless of whether the device is * in the building, so a phone that went home had its slot freed while * still being authorized: the owner then authorized a second device and * one of the two was an orphan nothing tracked. The longer * session_minutes is — and it is now meant to be a term — the more of * those accumulate. * * Slots are now freed by eviction, by expiry, or by ReconcilePortal. */ $live = PortalSession::live()->where('user_id', $user->id)->get(); // The same device coming back is a renewal, not a new device: it keeps // its slot and its row, and simply gets a fresh window. $existing = $live->firstWhere('mac_address', $clientMac); if (! $existing && $live->count() >= $mapping->max_devices) { return Inertia::render('Unifi/PortalDenied', [ 'reason' => "You already have {$live->count()} device(s) connected (limit: {$mapping->max_devices}).", 'sessions' => $live->map(fn ($s) => [ 'id' => $s->id, 'mac' => $s->mac_address, 'hostname' => $s->device_hostname, 'os' => $s->device_os, 'since' => $s->authorized_at->toISOString(), ]), 'can_disconnect' => true, ]); } // Authorize the guest $minutes = $mapping->session_minutes; $unifi->authorizeGuest($clientMac, $minutes); PortalSession::updateOrCreate( ['user_id' => $user->id, 'mac_address' => $clientMac], [ 'device_hostname' => $request->input('hostname'), 'device_os' => $request->input('os'), 'device_type' => $request->input('dev_cat'), 'ssid' => $ssid, 'vlan_id' => $mapping->vlan_id, 'ap_mac' => $apMac, 'is_active' => true, 'authorized_at' => now(), 'expires_at' => now()->addMinutes($minutes), 'is_randomized' => $isRandomized, ] ); // Redirect to the original URL the user was trying to reach. // // 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) { // Allow users to disconnect their own sessions abort_if($session->user_id !== auth()->id() && ! auth()->user()->can('unifi.auth'), 403); try { $unifi->kickClient($session->mac_address); $unifi->unauthorizeGuest($session->mac_address); } catch (\Throwable) {} $session->update(['is_active' => false]); return back()->with('success', 'Device disconnected.'); } // ── Private helpers ─────────────────────────────────────────────────────── private function resolveMapping($user): ?VlanMapping { $mappings = VlanMapping::orderBy('sort_order')->get(); // Try to match by email domain, group, OU, etc. foreach ($mappings as $mapping) { $matched = match ($mapping->match_type) { 'email_domain' => str_ends_with($user->email ?? '', '@' . $mapping->match_value), 'group' => $user->groups?->contains('name', $mapping->match_value) ?? false, 'ou' => str_contains($user->ou ?? '', $mapping->match_value), /* * Roles are the platform's own authority and need no external * sync to be correct, which is why quotas key off them. * * `default` is deliberately absent. It matched everybody, so its * existence made this an advisory list rather than an allow-list. * Returning null here is already the deny path: captiveCallback * renders "No WiFi access configured for your account." */ 'role' => $user->hasRole($mapping->match_value), default => false, }; if ($matched) return $mapping; } return null; } }