diff --git a/app/Auth/InsForgeUserProvider.php b/app/Auth/InsForgeUserProvider.php new file mode 100644 index 0000000..0c42415 --- /dev/null +++ b/app/Auth/InsForgeUserProvider.php @@ -0,0 +1,49 @@ +auth->profileById($identifier); + } + + public function retrieveByToken($identifier, #[\SensitiveParameter] $token): ?AuthenticatableContract + { + return null; + } + + public function updateRememberToken(AuthenticatableContract $user, #[\SensitiveParameter] $token): void {} + + public function retrieveByCredentials(#[\SensitiveParameter] array $credentials): ?AuthenticatableContract + { + if (! isset($credentials['email'], $credentials['password']) || ! is_string($credentials['email'])) { + return null; + } + + try { + return $this->auth->attemptLogin($credentials['email'], (string) $credentials['password']); + } catch (InsForgeException $exception) { + if ($exception->getCode() === 401) { + return null; + } + + throw $exception; + } + } + + public function validateCredentials(AuthenticatableContract $user, #[\SensitiveParameter] array $credentials): bool + { + return true; + } + + public function rehashPasswordIfRequired(AuthenticatableContract $user, #[\SensitiveParameter] array $credentials, bool $force = false): void {} +} diff --git a/app/Http/Middleware/RefreshInsForgeToken.php b/app/Http/Middleware/RefreshInsForgeToken.php new file mode 100644 index 0000000..b66d8f7 --- /dev/null +++ b/app/Http/Middleware/RefreshInsForgeToken.php @@ -0,0 +1,30 @@ +auth->refreshAccessToken()) { + Auth::guard('web')->logoutCurrentDevice(); + + if ($request->user()) { + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + } + + return $next($request); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..7eadaf1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,47 +2,48 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; -use Illuminate\Database\Eloquent\Factories\HasFactory; +use Filament\Models\Contracts\FilamentUser; +use Filament\Panel; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -class User extends Authenticatable +class User extends Authenticatable implements FilamentUser { - /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use Notifiable; - /** - * The attributes that are mass assignable. - * - * @var list - */ protected $fillable = [ + 'id', 'name', 'email', 'password', + 'role', + 'is_active', ]; - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ protected $hidden = [ 'password', 'remember_token', ]; - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'is_active' => 'boolean', ]; } + + public function canAccessPanel(Panel $panel): bool + { + return (bool) $this->is_active; + } + + public function isAdmin(): bool + { + return $this->role === 'admin'; + } + + public function isOperator(): bool + { + return $this->role === 'operator'; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c46b61e..b052054 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,12 +2,26 @@ namespace App\Providers; +use App\Auth\InsForgeUserProvider; +use App\Models\User; +use App\Services\InsForge\InsForgeAuthService; use App\Services\InsForge\InsForgeClient; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { + private const ADMIN_ONLY_ABILITIES = [ + 'manage-products', + 'manage-stock', + 'manage-drivers', + 'manage-users', + 'view-reports', + 'view-activity-logs', + ]; + /** * Register any application services. */ @@ -22,6 +36,8 @@ class AppServiceProvider extends ServiceProvider $config['timeout'], ); }); + + Auth::provider('insforge', fn () => new InsForgeUserProvider($this->app->make(InsForgeAuthService::class))); } /** @@ -29,6 +45,12 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + Gate::before(function (User $user, string $ability) { + if ($user->isAdmin()) { + return true; + } + + return in_array($ability, self::ADMIN_ONLY_ABILITIES) ? false : null; + }); } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 8ce4eb0..de84578 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,6 +2,7 @@ namespace App\Providers\Filament; +use App\Http\Middleware\RefreshInsForgeToken; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -51,6 +52,7 @@ class AdminPanelProvider extends PanelProvider SubstituteBindings::class, DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, + RefreshInsForgeToken::class, ]) ->authMiddleware([ Authenticate::class, diff --git a/app/Services/InsForge/InsForgeAuthService.php b/app/Services/InsForge/InsForgeAuthService.php new file mode 100644 index 0000000..69bc40b --- /dev/null +++ b/app/Services/InsForge/InsForgeAuthService.php @@ -0,0 +1,167 @@ +client->send('POST', '/api/auth/sessions', [ + 'email' => $email, + 'password' => $password, + ]); + + $body = $response->json() ?? []; + + $accessToken = $body['accessToken'] ?? $body['access_token'] ?? null; + + if (! is_string($accessToken) || $accessToken === '') { + throw new InsForgeException(null, 500, 'InsForge login response did not contain an access token.'); + } + + Session::put(self::SESSION_ACCESS_TOKEN, $accessToken); + Session::put(self::SESSION_REFRESH_COOKIE, $this->extractRefreshCookie($response) ?? ($body['refreshToken'] ?? null)); + + $authUser = is_array($body['user'] ?? null) ? $body['user'] : []; + + $profile = $this->ensureProfile( + id: $authUser['id'] ?? null, + email: $email, + password: $password, + fallbackName: $authUser['name'] + ?? (is_array($authUser['profile'] ?? null) ? ($authUser['profile']['name'] ?? null) : null) + ?? explode('@', $email)[0], + ); + + return $this->hydrateProfile($profile); + } + + public function profileById(?string $id): ?User + { + if ($id === null) { + return null; + } + + $profile = $this->client + ->withToken(Session::get(self::SESSION_ACCESS_TOKEN)) + ->getRecord('users', $id); + + if ($profile === null || ! ($profile['is_active'] ?? false)) { + return null; + } + + return $this->hydrateProfile($profile); + } + + public function refreshAccessToken(): bool + { + $cookie = Session::get(self::SESSION_REFRESH_COOKIE); + + if (! is_string($cookie) || $cookie === '') { + return false; + } + + try { + $response = $this->client->send('POST', '/api/auth/refresh', headers: ['Cookie' => $cookie]); + } catch (InsForgeException) { + return false; + } + + $accessToken = ($response->json() ?? [])['accessToken'] + ?? ($response->json() ?? [])['access_token'] + ?? null; + + if (! is_string($accessToken) || $accessToken === '') { + return false; + } + + Session::put(self::SESSION_ACCESS_TOKEN, $accessToken); + + return true; + } + + public function clearSession(): void + { + Session::forget([self::SESSION_ACCESS_TOKEN, self::SESSION_REFRESH_COOKIE]); + } + + public static function accessTokenExpiresInSeconds(): ?int + { + $token = Session::get(self::SESSION_ACCESS_TOKEN); + + if (! is_string($token) || substr_count($token, '.') !== 2) { + return null; + } + + $payload = json_decode(base64_decode(strtr(explode('.', $token)[1], '-_', '+/').str_repeat('=', 4 - strlen(explode('.', $token)[1]) % 4), true), true); + + return isset($payload['exp']) ? (int) $payload['exp'] - time() : null; + } + + private function ensureProfile(?string $id, string $email, string $password, string $fallbackName): array + { + $existing = $this->client->listRecords('users', ['email' => 'eq.'.$email])[0] ?? null; + + if ($existing !== null) { + return $existing; + } + + $attributes = array_filter([ + 'id' => $id, + 'name' => $fallbackName, + 'email' => $email, + 'password' => Hash::make($password), + 'role' => 'operator', + 'is_active' => true, + ], fn ($value) => $value !== null); + + $this->client->insertRecord('users', [$attributes], asUser: false); + + return $this->client->listRecords('users', ['email' => 'eq.'.$email])[0] ?? $attributes; + } + + private function hydrateProfile(array $profile): User + { + $user = new User; + + $user->forceFill([ + 'id' => $profile['id'], + 'name' => $profile['name'] ?? '', + 'email' => $profile['email'], + 'password' => $profile['password'] ?? '', + 'role' => $profile['role'] ?? 'operator', + 'is_active' => (bool) ($profile['is_active'] ?? false), + ]); + + return $user; + } + + private function extractRefreshCookie($response): ?string + { + foreach ((array) $response->headers() as $name => $values) { + if (strtolower((string) $name) !== 'set-cookie') { + continue; + } + + foreach ((array) $values as $value) { + $firstPair = explode(';', trim((string) $value))[0]; + + if (str_contains($firstPair, 'refresh')) { + return $firstPair; + } + } + } + + return null; + } +} diff --git a/app/Services/InsForge/InsForgeClient.php b/app/Services/InsForge/InsForgeClient.php index 9d9642f..4eb57f5 100644 --- a/app/Services/InsForge/InsForgeClient.php +++ b/app/Services/InsForge/InsForgeClient.php @@ -13,8 +13,7 @@ class InsForgeClient private readonly string $apiKey, private readonly int $timeout = 30, private readonly ?string $accessToken = null, - ) { - } + ) {} public function withToken(?string $accessToken): self { @@ -48,7 +47,7 @@ class InsForgeClient public function getRecord(string $table, string $id, array $params = []): ?array { - $params['id'] = 'eq.' . $id; + $params['id'] = 'eq.'.$id; $params['limit'] = 1; return $this->listRecords($table, $params)[0] ?? null; @@ -99,13 +98,13 @@ class InsForgeClient $query = []; foreach ($filters as $column => $value) { - $query[$column] = str_starts_with((string) $value, 'eq.') ? $value : 'eq.' . $value; + $query[$column] = str_starts_with((string) $value, 'eq.') ? $value : 'eq.'.$value; } return $query; } - private function request(string $method, string $path, array $body = null, array $query = [], bool $asUser = false): Response + public function send(string $method, string $path, array $body = [], array $query = [], bool $asUser = false, array $headers = []): Response { if ($asUser && $this->accessToken === null) { throw new InsForgeException(null, 401, 'No user access token available for this request.'); @@ -114,13 +113,14 @@ class InsForgeClient try { $http = Http::baseUrl(rtrim($this->baseUrl, '/')) ->timeout($this->timeout) + ->withHeaders($headers) ->withToken($asUser ? $this->accessToken : $this->apiKey); $verb = strtolower($method); $response = in_array($verb, ['get', 'head']) ? $http->{$verb}($path, $query) - : $http->{$verb}($path, $body ?? []); + : $http->{$verb}($path, $body); throw_unless($response->successful(), fn () => InsForgeException::fromResponse( $response->status(), @@ -129,7 +129,12 @@ class InsForgeClient return $response; } catch (ConnectionException $e) { - throw new InsForgeException(null, 0, 'InsForge connection failed: ' . $e->getMessage()); + throw new InsForgeException(null, 0, 'InsForge connection failed: '.$e->getMessage()); } } + + private function request(string $method, string $path, ?array $body = null, array $query = [], bool $asUser = false): Response + { + return $this->send($method, $path, $body ?? [], $query, $asUser); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 7b162da..4b2913f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware) { - // + $middleware->alias([ + 'insforge.refresh' => RefreshInsForgeToken::class, + ]); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 22744d1..ec5248a 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,6 +1,9 @@ [ 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'driver' => 'insforge', + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c..c4ceb07 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,12 +2,13 @@ namespace Database\Factories; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; /** - * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> + * @extends Factory */ class UserFactory extends Factory { diff --git a/phpunit.xml b/phpunit.xml index 506b9a3..7ed4853 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -29,5 +29,7 @@ + + diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php new file mode 100644 index 0000000..c9340a5 --- /dev/null +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -0,0 +1,109 @@ + '11111111-1111-1111-1111-111111111111', + 'name' => 'Admin BerasPro', + 'email' => 'admin@beraspro.test', + 'password' => '$2y$04$hash', + 'role' => 'admin', + 'is_active' => true, + ], $overrides); + } + + public function test_login_page_renders_for_guest(): void + { + $this->get('/admin/login')->assertOk(); + } + + public function test_unauthenticated_user_is_redirected_to_login(): void + { + $this->get('/admin')->assertRedirect('/admin/login'); + } + + public function test_user_can_login_with_valid_credentials(): void + { + InsForgeFake::fakeLoginSuccess($this->adminProfile()); + + Livewire::test(Login::class) + ->fillForm(['email' => 'admin@beraspro.test', 'password' => 'secret']) + ->call('authenticate') + ->assertHasNoErrors(); + + $this->assertAuthenticated(); + + $this->assertSame( + InsForgeFake::$accessToken, + Session::get(InsForgeAuthService::SESSION_ACCESS_TOKEN), + ); + + $user = auth()->user(); + + $this->assertInstanceOf(User::class, $user); + $this->assertTrue($user->isAdmin()); + } + + public function test_mirror_profile_is_created_when_missing(): void + { + InsForgeFake::fakeLoginSuccess($this->adminProfile()); + unset(InsForgeFake::$profiles['admin@beraspro.test']); + + Livewire::test(Login::class) + ->fillForm(['email' => 'admin@beraspro.test', 'password' => 'secret']) + ->call('authenticate'); + + $this->assertArrayHasKey('admin@beraspro.test', InsForgeFake::$profiles); + $this->assertSame('operator', InsForgeFake::$profiles['admin@beraspro.test']['role']); + } + + public function test_invalid_credentials_do_not_authenticate(): void + { + InsForgeFake::fakeLoginFailure(); + + Livewire::test(Login::class) + ->fillForm(['email' => 'admin@beraspro.test', 'password' => 'wrong']) + ->call('authenticate') + ->assertHasErrors(); + + $this->assertGuest(); + } + + public function test_inactive_user_cannot_access_panel(): void + { + InsForgeFake::fakeLoginSuccess($this->adminProfile(['is_active' => false])); + + Livewire::test(Login::class) + ->fillForm(['email' => 'admin@beraspro.test', 'password' => 'secret']) + ->call('authenticate'); + + $this->get('/admin')->assertRedirect('/admin/login'); + $this->assertGuest(); + } + + public function test_logout_clears_insforge_tokens(): void + { + InsForgeFake::fakeLoginSuccess($this->adminProfile()); + + Livewire::test(Login::class) + ->fillForm(['email' => 'admin@beraspro.test', 'password' => 'secret']) + ->call('authenticate'); + + $this->post('/admin/logout'); + + $this->assertGuest(); + $this->assertNull(Session::get(InsForgeAuthService::SESSION_ACCESS_TOKEN)); + } +} diff --git a/tests/Feature/Auth/AuthorizationTest.php b/tests/Feature/Auth/AuthorizationTest.php new file mode 100644 index 0000000..f162405 --- /dev/null +++ b/tests/Feature/Auth/AuthorizationTest.php @@ -0,0 +1,66 @@ +forceFill([ + 'id' => $profile['id'], + 'name' => $profile['name'], + 'email' => $profile['email'], + 'role' => $profile['role'], + 'is_active' => true, + ]); + + $this->actingAs($user); + + return $user; + } + + public function test_admin_is_granted_every_ability(): void + { + $this->loginUser([ + 'id' => '22222222-2222-2222-2222-222222222222', + 'name' => 'Admin', + 'email' => 'admin@beraspro.test', + 'role' => 'admin', + ]); + + foreach (self::ADMIN_ONLY_ABILITIES as $ability) { + $this->assertTrue(Gate::allows($ability), "admin should be allowed {$ability}"); + } + } + + public function test_operator_is_denied_admin_only_abilities(): void + { + $this->loginUser([ + 'id' => '33333333-3333-3333-3333-333333333333', + 'name' => 'Operator', + 'email' => 'operator@beraspro.test', + 'role' => 'operator', + ]); + + foreach (self::ADMIN_ONLY_ABILITIES as $ability) { + $this->assertTrue(Gate::denies($ability), "operator should be denied {$ability}"); + } + } +} diff --git a/tests/Support/InsForgeFake.php b/tests/Support/InsForgeFake.php new file mode 100644 index 0000000..10eaf5f --- /dev/null +++ b/tests/Support/InsForgeFake.php @@ -0,0 +1,115 @@ + $profile['id'], + 'exp' => now()->addHour()->getTimestamp(), + ])).'.sig'; + + self::$profiles[$profile['email']] = $profile; + + Http::fake([ + '*/api/auth/sessions' => Http::response([ + 'accessToken' => self::$accessToken, + 'refreshToken' => 'fake-refresh-token', + 'user' => [ + 'id' => $profile['id'], + 'email' => $profile['email'], + 'name' => $profile['name'], + ], + ], 200, [ + 'Set-Cookie' => 'insforge_refresh_token=fake-refresh-cookie; Path=/; HttpOnly', + ]), + '*/api/auth/refresh' => Http::response([ + 'accessToken' => self::$accessToken, + ], 200), + '*/api/database/records/users*' => function (Request $request) { + return self::handleUsersRequest($request); + }, + ]); + } + + public static function fakeLoginFailure(): void + { + self::reset(); + + Http::fake([ + '*/api/auth/sessions' => Http::response([ + 'error' => 'AUTH_UNAUTHORIZED', + 'message' => 'Invalid credentials', + 'statusCode' => 401, + ], 401), + ]); + } + + public static function fakeProfile(array $profile): void + { + self::$profiles[$profile['email']] = $profile; + } + + private static function handleUsersRequest(Request $request) + { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY) ?? '', $query); + $method = strtoupper($request->method()); + + if ($method === 'GET') { + foreach (self::$profiles as $profile) { + if (($query['email'] ?? null) === 'eq.'.$profile['email'] + || ($query['id'] ?? null) === 'eq.'.$profile['id']) { + return Http::response([$profile], 200); + } + } + + return Http::response([], 200); + } + + if ($method === 'POST') { + $payload = $request->data()[0] ?? []; + + if (! is_array($payload)) { + return Http::response([], 201); + } + + self::$profiles[$payload['email']] = array_merge([ + 'role' => 'operator', + 'is_active' => true, + ], $payload); + + return Http::response([], 201); + } + + if ($method === 'PATCH') { + $targetId = str_replace('eq.', '', $query['id'] ?? ''); + + foreach (self::$profiles as &$profile) { + if ($profile['id'] === $targetId) { + $profile = array_merge($profile, $request->data()); + } + } + unset($profile); + + return Http::response([], 200); + } + + return Http::response([], 200); + } +}