This commit is contained in:
2026-03-07 18:54:33 -06:00
parent 459e3ef864
commit 2347aa250e
13 changed files with 226 additions and 34 deletions

View File

@@ -71,4 +71,5 @@ EVEONLINE_REDIRECT_URL=replace_this_with_eve_developer_redirect_uri
JWT_SECRET=replace_this_with_a_long_random_secret
JWT_TTL=3600
JWT_ISSUER="${APP_NAME}"
JWT_REFRESH_INTERVAL=3600

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\JwtService;
use Illuminate\Console\Command;
class RefreshUserJwts extends Command
{
protected $signature = 'app:refresh-user-jwts';
protected $description = 'Refresh user JWTs that are older than the configured refresh interval.';
public function handle(JwtService $jwtService): int
{
$refreshInterval = (int) config('jwt.refresh_interval', 3600);
$threshold = now()->subSeconds($refreshInterval);
User::query()
->where(function ($query) use ($threshold) {
$query->whereNull('user_jwt')
->orWhereNull('user_jwt_issued_at')
->orWhere('user_jwt_issued_at', '<=', $threshold);
})
->chunkById(100, function ($users) use ($jwtService) {
foreach ($users as $user) {
$jwtService->forceRefresh($user);
}
});
$this->info('Stale user JWTs refreshed successfully.');
return self::SUCCESS;
}
}

View File

@@ -29,33 +29,25 @@ class EveLoginController extends Controller
return Socialite::driver('eveonline')->scopes(['publicData'])->redirect();
}
public function handleProviderCallback(Request $request): RedirectResponse
public function handleProviderCallback(Request $request, JwtService $jwtService): RedirectResponse
{
try {
// Stateless can help in some deployments, but keep stateful by default.
// If you run into "Invalid state" issues behind proxies, switch to ->stateless()
$ssoUser = Socialite::driver('eveonline')->user();
Debugbar::warning($ssoUser);
// Socialite user basics
$characterId = (int) $ssoUser->getId();
Debugbar::info($characterId);
$characterName = $ssoUser->getName() ?: ($ssoUser->getNickname() ?? 'Unknown');
Debugbar::info($characterName);
// Provider-specific extra payload sometimes appears in user array / token response.
// Well defensively extract what we can.
$raw = $ssoUser->user ?? [];
$characterOwnerHash =
$raw['CharacterOwnerHash'] ?? $raw['character_owner_hash'] ?? $raw['owner_hash'] ?? '';
Debugbar::info($characterOwnerHash);
$raw['CharacterOwnerHash']
?? $raw['character_owner_hash']
?? $raw['owner_hash']
?? '';
$token = $ssoUser->token;
Debugbar::info($token);
$refreshToken = $ssoUser->refreshToken ?? null;
Debugbar::info($refreshTokeen);
$expiresIn = $ssoUser->expiresIn ?? null;
Debugbar::info($expiresIn);
$user = User::updateOrCreate(
['character_id' => $characterId],
@@ -65,26 +57,22 @@ class EveLoginController extends Controller
'token' => $token,
'refresh_token' => $refreshToken,
'expiresIn' => $expiresIn,
// "user" holds jwt - you can set it later when you add JWT issuance.
'user_jwt' => null,
]
);
Debugbar::info($user);
//Issue JWT and store in the "user" column per your spec
$jwt = $jwtService->make($user);
$user->user_jwt = $jwt;
$user->save();
// Always regenerate JWT on successful login
$jwtService->forceRefresh($user);
Auth::login($user, true);
$request->session()->regenerate();
return redirect()->route('dashboard');
} catch (Throwable $e) {
// For now: fail back to login with a generic error.
// Later you can add logging/telemetry.
Debugbar::addThrowable($e);
return redirect()->route('login')->with('error', $e);
report($e);
return redirect()
->route('login')
->with('error', 'SSO login failed. Please try again.');
}
}
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use App\Services\JwtService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RefreshUserJwt
{
public function handle(Request $request, Closure $next, JwtService $jwtService): Response
{
if ($request->user()) {
$jwtService->refreshIfNeeded($request->user());
}
return $next($request);
}
}

View File

@@ -17,6 +17,9 @@ class User extends Authenticatable
'refresh_token',
'expiresIn',
'user_jwt', // holds jwt (per spec)
'user_jwt_issued-at',
'user_jwt_expires_at',
'privleges_version',
];
protected $hidden = [
@@ -29,6 +32,14 @@ class User extends Authenticatable
{
return [
'expiresIn' => 'integer',
'privileges_version' => 'integer',
'user_jwt_issued_at' => 'datetime',
'user_jwt_expires_at' => 'datetime',
];
}
public function markPrivilegesChanged(): void
{
$this->increment('privileges_version');
}
}

View File

@@ -10,6 +10,57 @@ use RuntimeException;
class JwtService
{
public function issue(User $user, bool $save = true): string
{
$secret = config('jwt.secret');
if (! $secret) {
throw new RuntimeException('JWT secret is not configured.');
}
$issuedAt = Carbon::now();
$ttl = (int) config('jwt.ttl', 3600);
$expiresAt = $issuedAt->copy()->addSeconds($ttl);
$payload = [
'iss' => config('jwt.issuer', config('app.name')),
'sub' => (string) $user->id,
'iat' => $issuedAt->timestamp,
'nbf' => $issuedAt->timestamp,
'exp' => $expiresAt->timestamp,
'character_id' => $user->character_id,
'character_name' => $user->character_name,
'character_owner_hash' => $user->character_owner_hash,
//Critical for privileg-aware JWT invalidation/regeneration
'privileges_version' => (int) $user->privileges_version,
];
$jwt = JWT::encode($payload, $secret, 'HS256');
if ($save) {
$user->forceFill([
'user_jwt' => $jwt,
'user_jwt_issued_at' => $issuedAt,
'user_jwt_expires_at' => $expiresAt,
])->save();
}
return $jwt;
}
public function refreshIfNeeded(User $user): string
{
$refreshInterval = (int) config('jwt.refresh_interval', 3600);
if ($user->jwtNeedsRefresh($refreshInterval)) {
return $this->issue($user);
}
return $user->user_jwt;
}
public function make(User $user): string
{
$secret = config('jwt.secret');

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class UserPrivilegeService
{
public function privilegesChanged(User $user, ?callable $mutator = null): User
{
return DB::transaction(function () use ($user, $mutator) {
if ($mutator) {
$mutator($user);
}
$user->increment('privileges_version');
$user->refresh();
app(JwtService::class)->forceRefresh($user);
return $user;
});
}
}

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\RefreshUserJwt;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@@ -11,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->web(append: [
RefreshUserJwt::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//

View File

@@ -4,4 +4,7 @@ return [
'secret' => env('JWT_SECRET'),
'ttl' => env('JWT_TTL', 3600),
'issuer' => env('JWT_ISSUER', env('APP_NAME', 'Framework')),
//Regenerate token if older than this many seconds
'refresh_interval' => (int) env('JWT_REFRESH_INTERVAL', 3600),
];

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('user_jwt_issued_at')->nullable()->after('user_jwt');
$table->timestamp('user_jwt_expires_at')->nullable()->after('user_jwt_issued_at');
// Tracks privilege changes. Increment this whenever privileges/roles change.
$table->unsignedBigInteger('privileges_version')->default(1)->after('expiresIn');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'user_jwt_issued_at',
'user_jwt_expires_at',
'privileges_version',
]);
});
}
};

View File

@@ -1,8 +1,28 @@
@extends('layouts.user.dashb4')
@section('content')
<br>
@if(auth()->user()->hasRole('User') || auth()->user()->hasRole('Admin'))
Welcome to the dashboard where you are now logged into.
@endif
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard</title>
</head>
<body style="font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;">
<div style="max-width: 900px; margin: 40px auto; padding: 0 16px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h1>Hello World</h1>
@endsection
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit">Logout</button>
</form>
</div>
<p>Logged in as: <strong>{{ auth()->user()->character_name }}</strong> ({{ auth()->user()->character_id }})</p>
<p>Privileges version: <strong>{{ auth()->user()->privileges_version }}</strong></p>
<p>JWT issued at: <strong>{{ optional(auth()->user()->user_jwt_issued_at)?->toDateTimeString() }}</strong></p>
<p>JWT expires at: <strong>{{ optional(auth()->user()->user_jwt_expires_at)?->toDateTimeString() }}</strong></p>
<h2>JWT</h2>
<textarea readonly style="width:100%; min-height:180px;">{{ auth()->user()->user_jwt }}</textarea>
</div>
</body>
</html>

View File

@@ -17,6 +17,9 @@
</div>
<p>Logged in as: <strong>{{ auth()->user()->character_name }}</strong> ({{ auth()->user()->character_id }})</p>
<p>Privileges version: <strong>{{ auth()->user()->privileges_version }}</strong></p>
<p>JWT issued at: <strong>{{ optional(auth()->user()->user_jwt_issued_at)?->toDateTimeString() }}</strong></p>
<p>JWT expires at: <strong>{{ optional(auth()->user()->user_jwt_expires_at)?->toDateTimeString() }}</strong></p>
<h2>JWT</h2>
<textarea readonly style="width:100%; min-height:180px;">{{ auth()->user()->user_jwt }}</textarea>

View File

@@ -6,3 +6,5 @@ use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('app:refresh-user-jwts')->everyMinute();