Skip to content

Commit b51a727

Browse files
committed
Add initial restify API
- add dependancies - add command to add api tokens - add webview to create api tokens - add authentication enforement - add health endpoint
1 parent 8a5a1b7 commit b51a727

18 files changed

Lines changed: 1567 additions & 89 deletions
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Console\LnmsCommand;
6+
use App\Models\User;
7+
use Symfony\Component\Console\Input\InputArgument;
8+
use Symfony\Component\Console\Input\InputOption;
9+
10+
class ApiTokenCommand extends LnmsCommand
11+
{
12+
protected $name = 'api:token';
13+
protected $description = 'Manage API tokens for users';
14+
15+
public function __construct()
16+
{
17+
parent::__construct();
18+
19+
$this->addArgument('action', InputArgument::REQUIRED, 'Action to perform: create, list, revoke');
20+
$this->addArgument('username', InputArgument::REQUIRED, 'Username to manage tokens for');
21+
$this->addOption('token-name', null, InputOption::VALUE_REQUIRED, 'Name for the token', 'api-token');
22+
$this->addOption('token-id', null, InputOption::VALUE_REQUIRED, 'Token ID to revoke (for revoke action)');
23+
}
24+
25+
public function handle(): int
26+
{
27+
$action = $this->argument('action');
28+
$username = $this->argument('username');
29+
30+
$user = User::where('username', $username)->first();
31+
32+
if (! $user) {
33+
$this->error("User '{$username}' not found.");
34+
35+
return 1;
36+
}
37+
38+
return match ($action) {
39+
'create' => $this->createToken($user),
40+
'list' => $this->listTokens($user),
41+
'revoke' => $this->revokeToken($user),
42+
default => $this->invalidAction($action),
43+
};
44+
}
45+
46+
private function createToken(User $user): int
47+
{
48+
$name = $this->option('token-name');
49+
$token = $user->createToken($name);
50+
51+
$this->info('Token created successfully.');
52+
$this->newLine();
53+
$this->line($token->plainTextToken);
54+
$this->newLine();
55+
$this->warn('Save this token — it will not be shown again.');
56+
57+
return 0;
58+
}
59+
60+
private function listTokens(User $user): int
61+
{
62+
$tokens = $user->tokens;
63+
64+
if ($tokens->isEmpty()) {
65+
$this->info("No tokens found for user '{$user->username}'.");
66+
67+
return 0;
68+
}
69+
70+
$this->table(
71+
['ID', 'Name', 'Last Used', 'Created'],
72+
$tokens->map(fn ($token) => [
73+
$token->id,
74+
$token->name,
75+
$token->last_used_at?->diffForHumans() ?? 'Never',
76+
$token->created_at->diffForHumans(),
77+
])
78+
);
79+
80+
return 0;
81+
}
82+
83+
private function revokeToken(User $user): int
84+
{
85+
$tokenId = $this->option('token-id');
86+
87+
if (! $tokenId) {
88+
$this->error('The --token-id option is required for the revoke action.');
89+
90+
return 1;
91+
}
92+
93+
$token = $user->tokens()->where('id', $tokenId)->first();
94+
95+
if (! $token) {
96+
$this->error("Token ID {$tokenId} not found for user '{$user->username}'.");
97+
98+
return 1;
99+
}
100+
101+
$token->delete();
102+
$this->info("Token '{$token->name}' (ID: {$tokenId}) revoked.");
103+
104+
return 0;
105+
}
106+
107+
private function invalidAction(string $action): int
108+
{
109+
$this->error("Unknown action '{$action}'. Use: create, list, revoke");
110+
111+
return 1;
112+
}
113+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Http\Controllers\Controller;
6+
use Illuminate\Http\JsonResponse;
7+
use Illuminate\Support\Facades\Cache;
8+
use Illuminate\Support\Facades\DB;
9+
use Throwable;
10+
11+
class HealthController extends Controller
12+
{
13+
public function __invoke(): JsonResponse
14+
{
15+
$checks = [
16+
'database' => $this->check(fn () => DB::connection()->select('SELECT 1')),
17+
'cache' => $this->check(function () {
18+
$key = 'health:ping:' . bin2hex(random_bytes(4));
19+
Cache::put($key, '1', 5);
20+
if (Cache::get($key) !== '1') {
21+
throw new \RuntimeException('cache round-trip mismatch');
22+
}
23+
Cache::forget($key);
24+
}),
25+
];
26+
27+
$status = collect($checks)->every(fn ($c) => $c['ok']) ? 'ok' : 'down';
28+
$httpStatus = $status === 'ok' ? 200 : 503;
29+
30+
return response()->json([
31+
'status' => $status,
32+
'checks' => $checks,
33+
], $httpStatus);
34+
}
35+
36+
private function check(callable $probe): array
37+
{
38+
try {
39+
$probe();
40+
41+
return ['ok' => true];
42+
} catch (Throwable $e) {
43+
return ['ok' => false, 'error' => $e->getMessage()];
44+
}
45+
}
46+
}

app/Http/Controllers/ApiAccessController.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424
namespace App\Http\Controllers;
2525

2626
use App\Models\ApiToken;
27+
use App\Models\User;
2728
use Illuminate\Http\JsonResponse;
2829
use Illuminate\Http\RedirectResponse;
2930
use Illuminate\Http\Request;
3031
use Illuminate\View\View;
32+
use Laravel\Sanctum\PersonalAccessToken;
3133
use LibreNMS\Authentication\LegacyAuth;
3234

3335
class ApiAccessController extends Controller
@@ -46,8 +48,15 @@ public function index(Request $request): View
4648
->orderBy('id')
4749
->get();
4850

51+
$v1Tokens = PersonalAccessToken::query()
52+
->where('tokenable_type', User::class)
53+
->where('tokenable_id', $user->user_id)
54+
->orderBy('id')
55+
->get();
56+
4957
return view('user.api-access', [
5058
'tokens' => $tokens,
59+
'v1_tokens' => $v1Tokens,
5160
'legacy_auth_type' => LegacyAuth::getType(),
5261
]);
5362
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use Closure;
6+
use Illuminate\Http\Request;
7+
use Symfony\Component\HttpFoundation\Response;
8+
9+
class EnforceJsonApi
10+
{
11+
public const CONTENT_TYPE = 'application/vnd.api+json';
12+
13+
public function handle(Request $request, Closure $next): Response
14+
{
15+
// Reject request bodies with wrong Content-Type (JSON:API §5.3)
16+
if ($request->getContent() && ! $this->isJsonApi($request->header('Content-Type', ''))) {
17+
return response()->json([
18+
'errors' => [['status' => '415', 'title' => 'Unsupported Media Type']],
19+
], 415)->withHeaders(['Content-Type' => self::CONTENT_TYPE]);
20+
}
21+
22+
// Tell Laravel to treat this as a JSON request
23+
$request->headers->set('Accept', 'application/json');
24+
25+
$response = $next($request);
26+
27+
// Set JSON:API Content-Type on responses (§5.2)
28+
$response->headers->set('Content-Type', self::CONTENT_TYPE);
29+
30+
return $response;
31+
}
32+
33+
private function isJsonApi(string $contentType): bool
34+
{
35+
// Accept both application/vnd.api+json and application/json for pragmatism
36+
return str_contains($contentType, 'application/vnd.api+json')
37+
|| str_contains($contentType, 'application/json');
38+
}
39+
}

app/Models/User.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Illuminate\Support\Collection;
1313
use Illuminate\Support\Facades\Gate;
1414
use Illuminate\Support\Facades\Hash;
15+
use Laravel\Sanctum\HasApiTokens;
1516
use LibreNMS\Authentication\LegacyAuth;
1617
use NotificationChannels\WebPush\HasPushSubscriptions;
1718
use Permissions;
@@ -22,6 +23,7 @@
2223
*/
2324
class User extends Authenticatable
2425
{
26+
use HasApiTokens;
2527
use HasFactory;
2628
use HasPushSubscriptions;
2729
use HasRoles;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
namespace App\Providers;
4+
5+
use App\Http\Controllers\Api\V1\HealthController;
6+
use Binaryk\LaravelRestify\Bootstrap\RoutesBoot;
7+
use Binaryk\LaravelRestify\Restify;
8+
use Binaryk\LaravelRestify\RestifyApplicationServiceProvider;
9+
use Illuminate\Support\Facades\Gate;
10+
use Illuminate\Support\Facades\Route;
11+
12+
class RestifyServiceProvider extends RestifyApplicationServiceProvider
13+
{
14+
protected function gate(): void
15+
{
16+
Gate::define('viewRestify', function ($user = null) {
17+
return true;
18+
});
19+
}
20+
21+
protected function routes(): void
22+
{
23+
// v1 custom endpoints that are not Restify repositories. The health
24+
// endpoint is intentionally public (no auth middleware).
25+
Route::prefix('api/v1')->group(function (): void {
26+
Route::get('health', HealthController::class)->name('v1.health');
27+
});
28+
29+
parent::routes();
30+
31+
// Parent only registers routes in console (for route:list) and
32+
// skips both web requests and unit tests. Register for both.
33+
if (! app()->runningInConsole() || app()->runningUnitTests()) {
34+
app(RoutesBoot::class)->boot();
35+
}
36+
}
37+
38+
public function boot(): void
39+
{
40+
parent::boot();
41+
42+
// No model repositories are registered yet. Add repository classes
43+
// here as v1 resources are introduced.
44+
Restify::repositories([]);
45+
}
46+
}

bootstrap/providers.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@
88
App\Providers\DatastoreServiceProvider::class,
99
App\Providers\SnmptrapProvider::class,
1010
App\Providers\PluginProvider::class,
11+
Binaryk\LaravelRestify\LaravelRestifyServiceProvider::class,
12+
App\Providers\RestifyServiceProvider::class,
1113
];

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"ext-xml": "*",
2828
"ext-zlib": "*",
2929
"amenadiel/jpgraph": "^4",
30+
"binaryk/laravel-restify": "^10.4.6",
3031
"clue/socket-raw": "^1.4",
3132
"dapphp/radius": "^3.0",
3233
"easybook/geshi": "^1.0.8",
@@ -38,6 +39,7 @@
3839
"justinrainbow/json-schema": "^6.4",
3940
"laravel-notification-channels/webpush": "^10.0",
4041
"laravel/framework": "^12.10",
42+
"laravel/sanctum": "^4.3.1",
4143
"laravel/tinker": "^3.0",
4244
"laravel/ui": "^4.6",
4345
"librenms/laravel-vue-i18n-generator": "dev-master",

0 commit comments

Comments
 (0)