4 Commits

Author SHA1 Message Date
f5848907f5 feat(access): strict allowlist + add groups by search
* UnifiPageGrant::userCanAccess no longer falls back to "open" when a
  page has no grants saved. Pages now require an explicit grant for
  every non-super-admin user — either a direct user grant or via a
  group they belong to. Matches the new dashboard-wide access model.
* Route enforcement returns 404 (was 403) so ungranted users can't even
  confirm the page exists.
* New /settings/pages-access/groups/search endpoint mirrors the
  user typeahead. Groups are no longer all listed by default — only
  super-admin groups (locked-on) and groups with at least one existing
  grant show up in the matrix. Operators add more via search.

v1.7.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:59:28 -04:00
f953fde2be release: 1.7.0 — rolls up the 1.6.1/1.6.2/1.6.3 patch series
Bundled cut for the stable channel. Contents since 1.6.0:

* fix(webhooks): test endpoint formats payload per platform (Google
  Chat / Slack / Discord / Teams) so the Test URL button actually
  succeeds against those targets instead of getting a 400 back.
* fix(schema): add missing unifi_device_states.consecutive_count
  column the scheduled snapshot capture was failing to insert.
* feat(rotate): persist the active rotated password as
  unifi.password_rotation.last_password whenever a whole-SSID
  rotation succeeds. Surfaced in Settings → Tasks under the wordlist.
* feat(api): new GET /api/unifi/wifi/current-password JSON endpoint
  for external signage / kiosks. Token-protected via
  Authorization: Bearer or ?token= query. 401 / 503 / 404 on missing
  auth, disabled API, or no rotation yet.
* feat(settings): "Expose WiFi password API" checkbox under the
  rotate-passwords block. Off by default. Generate / Regenerate /
  Clear token controls and a copy-paste curl example.

No breaking changes. Drop-in upgrade from 1.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:50:44 -04:00
9a37eda302 feat(api): explicit enable toggle for WiFi password endpoint
Previously the API was implicitly active whenever a token existed.
Now there's an explicit unifi.api.enabled setting that gates it:

* WifiApiController returns 503 ("API disabled") when the setting is
  off, even if a valid token is presented. Stops the endpoint from
  silently working if a token is lying around.
* Settings page exposes the toggle under the Rotate-WiFi-Passwords
  block. With it off, the token / URL / curl example are hidden.
* The form submit handles the new api_enabled boolean.

v1.6.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:44:57 -04:00
4b29f55518 feat(rotate): persist current password; add token-protected API
* RotatePasswords now stores the active wordlist entry as
  unifi.password_rotation.last_password whenever a whole-SSID rotation
  succeeds. Per-PPSK rotation continues to store passwords on each
  PPSK row as before.
* Settings → Tasks tab surfaces the current password in bold beneath
  the wordlist textarea so operators can quickly check what's live.
* New JSON endpoint GET /api/unifi/wifi/current-password returns
  {"password": "...", "rotated_at": "..."}. Protected by a token stored
  in unifi.api_token — pass as Authorization: Bearer <token> or
  ?token=<token>. 401 on bad/missing token, 503 if no token is
  configured, 404 if no rotation has happened yet.
* Settings page lets super-admins Generate / Regenerate / Clear the
  token. Generated tokens are 48-char hex from bin2hex(random_bytes(24)).
* The endpoint lives outside the web/auth middleware so external
  signage / kiosks can hit it without a session cookie.

v1.6.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:42:13 -04:00
8 changed files with 122 additions and 10 deletions

View File

@@ -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.6.1", "version": "1.7.1",
"type": "library", "type": "library",
"license": "MIT", "license": "MIT",
"autoload": { "autoload": {

View File

@@ -68,6 +68,9 @@ class RotatePasswords extends Command
if ($rotated) { if ($rotated) {
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String()); Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
// Persist the active password so it can be displayed in
// the Settings page and exposed via the API endpoint.
Setting::set('unifi.password_rotation.last_password', $password);
$this->info('Rotated password for ' . count($rotated) . ' SSID(s).'); $this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
} }

View File

@@ -40,6 +40,15 @@ class UnifiPagesAccessController extends Controller
$grantedUserIds = $grants->flatten(1)->where('grantee_type', 'user')->pluck('grantee_id')->unique(); $grantedUserIds = $grants->flatten(1)->where('grantee_type', 'user')->pluck('grantee_id')->unique();
$users = User::whereIn('id', $grantedUserIds)->orderBy('name')->get(['id', 'name', 'email']); $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']);
return response()->json([ return response()->json([
'pages' => $pages->map(fn ($p) => [ 'pages' => $pages->map(fn ($p) => [
'id' => $p->id, 'id' => $p->id,
@@ -49,7 +58,7 @@ class UnifiPagesAccessController extends Controller
'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(), 'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(),
])->values(), ])->values(),
'users' => $users, 'users' => $users,
'groups' => Group::orderBy('name')->get(['id', 'name', 'is_super']), 'groups' => $groups,
]); ]);
} }
@@ -76,6 +85,27 @@ class UnifiPagesAccessController extends Controller
return response()->json(['users' => $users]); 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]);
}
public function update(Request $request, NavItem $navItem) public function update(Request $request, NavItem $navItem)
{ {
$app = DashboardApp::where('slug', 'unifi')->first(); $app = DashboardApp::where('slug', 'unifi')->first();

View File

@@ -31,10 +31,26 @@ class UnifiSettingsController extends Controller
'rotationMinute' => (int) Setting::get('unifi.password_rotation.minute', 0), 'rotationMinute' => (int) Setting::get('unifi.password_rotation.minute', 0),
'rotationWordlist' => Setting::get('unifi.password_rotation.wordlist', ''), 'rotationWordlist' => Setting::get('unifi.password_rotation.wordlist', ''),
'rotationLastRotatedAt' => Setting::get('unifi.password_rotation.last_rotated_at', null), 'rotationLastRotatedAt' => Setting::get('unifi.password_rotation.last_rotated_at', null),
'rotationLastPassword' => Setting::get('unifi.password_rotation.last_password', null),
'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),
'apiToken' => Setting::get('unifi.api_token', null),
]); ]);
} }
public function regenerateApiToken()
{
$token = bin2hex(random_bytes(24));
Setting::set('unifi.api_token', $token);
return response()->json(['token' => $token]);
}
public function clearApiToken()
{
Setting::set('unifi.api_token', '');
return response()->json(['ok' => true]);
}
public function update(Request $request) public function update(Request $request)
{ {
$request->validate([ $request->validate([
@@ -56,6 +72,7 @@ class UnifiSettingsController extends Controller
'rotation_minute' => 'nullable|integer|min:0|max:59', 'rotation_minute' => 'nullable|integer|min:0|max:59',
'rotation_wordlist' => 'nullable|string|max:20000', 'rotation_wordlist' => 'nullable|string|max:20000',
'ppsk_scheduling_enabled' => 'boolean', 'ppsk_scheduling_enabled' => 'boolean',
'api_enabled' => 'boolean',
]); ]);
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/')); Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
@@ -82,6 +99,7 @@ class UnifiSettingsController extends Controller
Setting::set('unifi.password_rotation.minute', $request->input('rotation_minute', 0)); Setting::set('unifi.password_rotation.minute', $request->input('rotation_minute', 0));
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' : '');
\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, '/')));

View File

@@ -0,0 +1,44 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
/**
* Token-protected JSON endpoints for external integrations (signage,
* kiosks, room displays, etc.) that need the current rotating WiFi
* password without going through the dashboard UI.
*/
class WifiApiController extends Controller
{
public function currentPassword(Request $request)
{
if (! Setting::get('unifi.api.enabled')) {
return response()->json(['error' => 'API disabled'], 503);
}
$expected = Setting::get('unifi.api_token');
if (! $expected) {
return response()->json(['error' => 'API token not configured'], 503);
}
$provided = $request->bearerToken() ?: $request->query('token');
if (! $provided || ! hash_equals($expected, $provided)) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$password = Setting::get('unifi.password_rotation.last_password');
if (! $password) {
return response()->json([
'error' => 'No rotated password recorded yet — wait for the next scheduled rotation or run unifi:rotate-passwords --force.',
], 404);
}
return response()->json([
'password' => $password,
'rotated_at' => Setting::get('unifi.password_rotation.last_rotated_at'),
]);
}
}

View File

@@ -29,18 +29,20 @@ class UnifiPageGrant extends Model
} }
/** /**
* True iff $user is allowed to access $navItem under this grant model. * True iff $user is allowed to access $navItem under strict allowlist
* Super-admins always pass. * semantics:
* If there are NO grants for the page, falls back to "open" (anyone * * super-admins (the model-level flag) always pass
* who can reach the route can access — same as before grants existed). * * otherwise the user must be a direct grantee, OR be in a group
* that is a grantee
*
* A page with NO grants saved is only visible to super-admins —
* the admin must explicitly authorize everyone else via the
* Access tab.
*/ */
public static function userCanAccess(User $user, NavItem $navItem): bool public static function userCanAccess(User $user, NavItem $navItem): bool
{ {
if ($user->is_super_admin) return true; if ($user->is_super_admin) return true;
$hasGrants = static::where('nav_item_id', $navItem->id)->exists();
if (! $hasGrants) return true;
$groupIds = $user->groups()->pluck('groups.id'); $groupIds = $user->groups()->pluck('groups.id');
return static::where('nav_item_id', $navItem->id) return static::where('nav_item_id', $navItem->id)

View File

@@ -44,7 +44,9 @@ class UnifiServiceProvider extends ServiceProvider
if (! $item) return; if (! $item) return;
if (! UnifiPageGrant::userCanAccess($user, $item)) { if (! UnifiPageGrant::userCanAccess($user, $item)) {
abort(403, 'You do not have access to this page.'); // 404 instead of 403 — don't leak that the page
// exists. The Access tab is the only way in.
abort(404);
} }
} catch (\Throwable) { } catch (\Throwable) {
// unifi_page_grants table may not exist yet on a fresh // unifi_page_grants table may not exist yet on a fresh

View File

@@ -9,6 +9,7 @@ use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController;
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController; use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
use Dashboard\Unifi\Http\Controllers\VlanGroupController; use Dashboard\Unifi\Http\Controllers\VlanGroupController;
use Dashboard\Unifi\Http\Controllers\WebhookController; use Dashboard\Unifi\Http\Controllers\WebhookController;
use Dashboard\Unifi\Http\Controllers\WifiApiController;
use Dashboard\Unifi\Http\Controllers\WifiController; use Dashboard\Unifi\Http\Controllers\WifiController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -77,6 +78,7 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
Route::middleware('super.admin')->group(function () { Route::middleware('super.admin')->group(function () {
Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index'); 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/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::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update'); Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
}); });
@@ -90,9 +92,20 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
Route::delete('/settings/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy'); Route::delete('/settings/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
Route::post('/settings/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test'); Route::post('/settings/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test');
Route::post('/settings/webhooks/test-url', [WebhookController::class, 'testUrl'])->name('webhooks.test-url'); Route::post('/settings/webhooks/test-url', [WebhookController::class, 'testUrl'])->name('webhooks.test-url');
// API-token management
Route::post('/settings/api-token/regenerate', [UnifiSettingsController::class, 'regenerateApiToken'])->name('settings.api-token.regenerate');
Route::delete('/settings/api-token', [UnifiSettingsController::class, 'clearApiToken']) ->name('settings.api-token.clear');
}); });
}); });
// ── Public API (token-protected) ──────────────────────────────────────────
// External integrations (signage, kiosks) hit these without session auth.
Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
Route::get('/wifi/current-password', [WifiApiController::class, 'currentPassword'])
->name('wifi.current-password');
});
// ── Captive portal callback (public — user redirected here by UniFi) ───── // ── Captive portal callback (public — user redirected here by UniFi) ─────
Route::middleware(['web', 'auth']) Route::middleware(['web', 'auth'])
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback']) ->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])