272 lines
12 KiB
PHP
272 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Dashboard\Unifi\Http\Controllers;
|
|
|
|
use Dashboard\Unifi\Models\KnownMac;
|
|
use Dashboard\Unifi\Models\PortalSession;
|
|
use Dashboard\Unifi\Models\VlanGroup;
|
|
use Dashboard\Unifi\Models\VlanMapping;
|
|
use Dashboard\Unifi\Services\UnifiApiClient;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Routing\Controller;
|
|
use Inertia\Inertia;
|
|
|
|
class PortalController extends Controller
|
|
{
|
|
// ── Settings page (VLAN mappings + known MACs) ────────────────────────────
|
|
|
|
public function settings()
|
|
{
|
|
return Inertia::render('Unifi/Portal', [
|
|
'vlanGroups' => 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_mac>&ap=<ap_mac>&t=<timestamp>&url=<original_url>&ssid=<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;
|
|
}
|
|
}
|