diff --git a/.env.example b/.env.example index 235b66e..1433095 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Console/Commands/RefreshUserJwts.php b/app/Console/Commands/RefreshUserJwts.php new file mode 100644 index 0000000..38dfad8 --- /dev/null +++ b/app/Console/Commands/RefreshUserJwts.php @@ -0,0 +1,35 @@ +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; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Auth/EveLoginController.php b/app/Http/Controllers/Auth/EveLoginController.php index 6486fa0..716d577 100644 --- a/app/Http/Controllers/Auth/EveLoginController.php +++ b/app/Http/Controllers/Auth/EveLoginController.php @@ -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. - // We’ll 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.'); } } -} \ No newline at end of file +} diff --git a/app/Http/Middleware/RefreshUserJwt.php b/app/Http/Middleware/RefreshUserJwt.php new file mode 100644 index 0000000..8da463a --- /dev/null +++ b/app/Http/Middleware/RefreshUserJwt.php @@ -0,0 +1,20 @@ +user()) { + $jwtService->refreshIfNeeded($request->user()); + } + + return $next($request); + } +} \ No newline at end of file diff --git a/app/Models/Auth/User.php b/app/Models/Auth/User.php index 539cc19..e81f19f 100644 --- a/app/Models/Auth/User.php +++ b/app/Models/Auth/User.php @@ -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'); + } } diff --git a/app/Services/JwtService.php b/app/Services/JwtService.php index 70008c7..c09d080 100644 --- a/app/Services/JwtService.php +++ b/app/Services/JwtService.php @@ -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'); diff --git a/app/Services/UserPrivilegesService.php b/app/Services/UserPrivilegesService.php new file mode 100644 index 0000000..8936ece --- /dev/null +++ b/app/Services/UserPrivilegesService.php @@ -0,0 +1,25 @@ +increment('privileges_version'); + $user->refresh(); + + app(JwtService::class)->forceRefresh($user); + + return $user; + }); + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..550dc7d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { - // + $middleware->web(append: [ + RefreshUserJwt::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/config/jwt.php b/config/jwt.php index d22d6e3..535d79d 100644 --- a/config/jwt.php +++ b/config/jwt.php @@ -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), ]; \ No newline at end of file diff --git a/database/migrations/2026_03_08_003737_add_jwt_lifecycle_columns_to_users_table.php b/database/migrations/2026_03_08_003737_add_jwt_lifecycle_columns_to_users_table.php new file mode 100644 index 0000000..fcca342 --- /dev/null +++ b/database/migrations/2026_03_08_003737_add_jwt_lifecycle_columns_to_users_table.php @@ -0,0 +1,30 @@ +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', + ]); + }); + } +}; \ No newline at end of file diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 3b5a547..76e32f1 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,8 +1,28 @@ -@extends('layouts.user.dashb4') -@section('content') -
-@if(auth()->user()->hasRole('User') || auth()->user()->hasRole('Admin')) -Welcome to the dashboard where you are now logged into. -@endif + + + + + + Dashboard + + +
+
+

Hello World

-@endsection \ No newline at end of file +
+ @csrf + +
+
+ +

Logged in as: {{ auth()->user()->character_name }} ({{ auth()->user()->character_id }})

+

Privileges version: {{ auth()->user()->privileges_version }}

+

JWT issued at: {{ optional(auth()->user()->user_jwt_issued_at)?->toDateTimeString() }}

+

JWT expires at: {{ optional(auth()->user()->user_jwt_expires_at)?->toDateTimeString() }}

+ +

JWT

+ +
+ + \ No newline at end of file diff --git a/resources/views/dashboard/dashboard.blade.php b/resources/views/dashboard/dashboard.blade.php index 704de19..76e32f1 100644 --- a/resources/views/dashboard/dashboard.blade.php +++ b/resources/views/dashboard/dashboard.blade.php @@ -17,6 +17,9 @@

Logged in as: {{ auth()->user()->character_name }} ({{ auth()->user()->character_id }})

+

Privileges version: {{ auth()->user()->privileges_version }}

+

JWT issued at: {{ optional(auth()->user()->user_jwt_issued_at)?->toDateTimeString() }}

+

JWT expires at: {{ optional(auth()->user()->user_jwt_expires_at)?->toDateTimeString() }}

JWT

diff --git a/routes/console.php b/routes/console.php index 3c9adf1..5f80d80 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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(); \ No newline at end of file