diff --git a/app/Data/Ui/Dashboard/DashboardStatsData.php b/app/Data/Ui/Dashboard/DashboardStatsData.php new file mode 100644 index 0000000..25564ad --- /dev/null +++ b/app/Data/Ui/Dashboard/DashboardStatsData.php @@ -0,0 +1,20 @@ +|null $roles + */ + public function __construct( + public string $name, + public string $email, + public string $initials, + public string $registeredAt, + /** @var DataCollection|null */ + #[DataCollectionOf(class: RoleData::class)] + public ?DataCollection $roles = null, + ) {} +} diff --git a/app/Http/Controllers/ResolveDashboardRouteController.php b/app/Http/Controllers/ResolveDashboardRouteController.php index 65f8d64..488ee5e 100644 --- a/app/Http/Controllers/ResolveDashboardRouteController.php +++ b/app/Http/Controllers/ResolveDashboardRouteController.php @@ -6,22 +6,37 @@ use App\Helpers\DashboardRoute; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; class ResolveDashboardRouteController extends Controller { public function __invoke(Request $request) { /** - * If the user is redirected after SAML SSO Login (Foritfy Response always redirects to dashboard), - * redirect them to the SSO endpoint, So that SAML SSO Login can be completed. + * If the user is redirected after SAML SSO Login (Fortify Response always redirects to dashboard), + * redirect them to the SSO endpoint so that SAML SSO Login can be completed. */ if (session()->has('saml_pending_request')) { + $user = $request->user(); + Log::info('ResolveDashboardRouteController: Intercepted pending SAML SSO request in session. Redirecting authenticated user to complete SAML flow.', [ + 'user_id' => $user?->id, + 'user_email' => $user?->email, + 'session_keys' => array_keys(session()->all()), + ]); + return redirect()->route('saml.sso'); } $user = $request->user(); $route = DashboardRoute::resolve($user->getRoleNames()); + Log::info('ResolveDashboardRouteController: Resolving dashboard landing route for authenticated user.', [ + 'user_id' => $user?->id, + 'user_email' => $user?->email, + 'roles' => $user?->getRoleNames(), + 'target_route' => $route, + ]); + return to_route($route); } } diff --git a/app/Http/Controllers/SamlIdpController.php b/app/Http/Controllers/SamlIdpController.php index 41258cc..86e17f0 100644 --- a/app/Http/Controllers/SamlIdpController.php +++ b/app/Http/Controllers/SamlIdpController.php @@ -20,20 +20,37 @@ public function __construct(private readonly SamlIdpService $samlService) {} */ public function sso(Request $request) { - // Retrieve SAMLRequest and RelayState (from request parameters or session fallback) $samlRequest = $request->input('SAMLRequest') ?? session('saml_pending_request'); $relayState = $request->input('RelayState') ?? session('saml_pending_relay_state'); + Log::info('SAML SSO Request received.', [ + 'ip' => $request->ip(), + 'method' => $request->method(), + 'has_saml_request' => ! empty($samlRequest), + 'has_relay_state' => ! empty($relayState), + 'session_has_pending' => session()->has('saml_pending_request'), + ]); + if (empty($samlRequest)) { - Log::warning('SAML SSO accessed without a SAMLRequest.'); + Log::warning('SAML SSO access rejected: Missing SAMLRequest parameter.', [ + 'ip' => $request->ip(), + ]); abort(400, 'Missing SAMLRequest parameter.'); } try { - // Parse the SAMLRequest XML + Log::info('Decoding and parsing SAMLRequest XML payload.'); $parsed = $this->samlService->parseRequest($samlRequest); + Log::info('SAMLRequest successfully parsed.', [ + 'request_id' => $parsed['requestId'], + 'issuer' => $parsed['issuer'], + 'acs_url' => $parsed['acsUrl'], + ]); } catch (Throwable $e) { - Log::error('Failed to parse SAMLRequest: '.$e->getMessage()); + Log::error('SAMLRequest parsing failed.', [ + 'ip' => $request->ip(), + 'error' => $e->getMessage(), + ]); abort(400, 'Invalid SAMLRequest: '.$e->getMessage()); } @@ -41,24 +58,35 @@ public function sso(Request $request) $connectedApp = $this->samlService->findConnectedApp($parsed['issuer']); if (! $connectedApp) { - Log::warning( - "SAML SP Issuer [{$parsed['issuer']}] is not registered.", - ); + Log::warning('SAML SSO access rejected: Unregistered Service Provider (EntityID / Issuer).', [ + 'issuer' => $parsed['issuer'], + 'request_id' => $parsed['requestId'], + 'ip' => $request->ip(), + ]); abort( 403, "The Service Provider [{$parsed['issuer']}] is not authorized in this system.", ); } + Log::info('SAML ConnectedApp matched successfully.', [ + 'app_id' => $connectedApp->id, + 'app_name' => $connectedApp->name, + 'status' => $connectedApp->status?->name, + ]); + if ('disconnected' === $connectedApp->status?->name) { - Log::warning( - "SAML SP Issuer [{$parsed['issuer']}] is registered but disconnected.", - ); + Log::warning('SAML SSO access rejected: Connected App is deactivated.', [ + 'app_id' => $connectedApp->id, + 'app_name' => $connectedApp->name, + 'request_id' => $parsed['requestId'], + ]); abort( 403, "The Service Provider [{$connectedApp->name}] is currently deactivated.", ); } + /** * @var array{ * saml?: array{ @@ -68,19 +96,25 @@ public function sso(Request $request) * } $settings */ $settings = $connectedApp->settings; - // Use the ACS URL from the request, falling back to the registered ACS URL $acsUrl = $parsed['acsUrl'] ?: $settings['saml']['acs_url'] ?? null; if (empty($acsUrl)) { - Log::error( - "No Assertion Consumer Service (ACS) URL defined for [{$connectedApp->name}].", - ); + Log::error('SAML SSO failed: Assertion Consumer Service (ACS) URL is undefined.', [ + 'app_id' => $connectedApp->id, + 'app_name' => $connectedApp->name, + 'request_id' => $parsed['requestId'], + ]); abort(400, 'Assertion Consumer Service (ACS) URL is not defined.'); } // Authenticate the User if (! Auth::check()) { - // Store the SAML details in session to complete flow post-login + Log::info('SAML SSO guest session intercepted. Persisting request parameters to session and redirecting to login.', [ + 'request_id' => $parsed['requestId'], + 'relay_state' => $relayState, + 'ip' => $request->ip(), + ]); + session([ 'saml_pending_request' => $samlRequest, 'saml_pending_relay_state' => $relayState, @@ -89,10 +123,18 @@ public function sso(Request $request) return redirect()->route('login'); } - // Generate Signed SAML Response /** @var User $user */ $user = Auth::user(); + Log::info('SAML SSO user authenticated session detected. Generating signed SAML Response assertion.', [ + 'user_id' => $user->id, + 'user_email' => $user->email, + 'app_id' => $connectedApp->id, + 'app_name' => $connectedApp->name, + 'acs_url' => $acsUrl, + 'request_id' => $parsed['requestId'], + ]); + try { $samlResponse = $this->samlService->generateResponse( $user, @@ -100,13 +142,26 @@ public function sso(Request $request) $parsed['requestId'], $acsUrl, ); + Log::info('Signed SAML Response assertion successfully generated.'); } catch (Throwable $e) { - Log::error('Failed to generate SAML Response: '.$e->getMessage()); + Log::error('SAML Response generation failed.', [ + 'user_id' => $user->id, + 'app_id' => $connectedApp->id, + 'error' => $e->getMessage(), + ]); abort(500, 'Cryptographic signing of SAML Response failed.'); } // Clear pending SAML request from session session()->forget(['saml_pending_request', 'saml_pending_relay_state']); + Log::info('Pending SAML session parameters cleared.'); + + Log::info('Rendering SAML HTTP-POST auto-submission redirect form.', [ + 'user_id' => $user->id, + 'app_name' => $connectedApp->name, + 'acs_url' => $acsUrl, + 'relay_state' => $relayState, + ]); // Return HTTP-POST Auto-Submission Page return view('saml.post_response', [ @@ -126,15 +181,23 @@ public function metadata(): Response $idpEntityId = route('saml.metadata'); $ssoUrl = route('saml.sso'); + Log::info('SAML IdP Metadata requested.', [ + 'ip' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'entity_id' => $idpEntityId, + 'sso_url' => $ssoUrl, + ]); + try { $metadataXml = $this->samlService->generateMetadata( $idpEntityId, $ssoUrl, ); + Log::info('SAML IdP Metadata XML descriptor generated successfully.'); } catch (Throwable $e) { - Log::error( - 'Failed to generate SAML IdP Metadata: '.$e->getMessage(), - ); + Log::error('SAML IdP Metadata XML generation failed.', [ + 'error' => $e->getMessage(), + ]); abort( 500, 'Failed to retrieve Identity Provider cryptographic identity.', diff --git a/app/Services/AdminDashboardService.php b/app/Services/AdminDashboardService.php new file mode 100644 index 0000000..e8a2007 --- /dev/null +++ b/app/Services/AdminDashboardService.php @@ -0,0 +1,147 @@ +count(); + $activeRoles = Role::query()->count(); + $servicesCount = ConnectedApp::query()->count(); + $revokedTodayCount = RestrictedUserPermission::query() + ->whereDate('created_at', today()) + ->count(); + + return new DashboardStatsData( + totalUsers: $totalUsers, + activeRoles: $activeRoles, + servicesCount: $servicesCount, + revokedTodayCount: $revokedTodayCount, + ); + } + + /** + * Get all roles mapped to DTOs for the roles manager. + * + * @return DataCollection + */ + public function getRoles(): DataCollection + { + $roles = Role::query() + ->visible() + ->with(['users', 'apps']) + ->get(); + + $mappedRoles = $roles->map(function ($role) { + /** @var Role $role */ + /** @var \Illuminate\Database\Eloquent\Collection $users */ + $users = $role->users; + /** @var \Illuminate\Database\Eloquent\Collection $apps */ + $apps = $role->apps; + + return new RoleData( + name: $role->name, + users: UserData::collect($users->map(fn ($user) => new UserData( + name: $user->name, + email: $user->email, + active: true, + )), DataCollection::class), + services: ServiceData::collect($apps->map(fn ($app) => new ServiceData( + name: $app->name, + )), DataCollection::class), + status: RolesStatusEnum::Active, + ); + }); + + return RoleData::collect($mappedRoles, DataCollection::class); + } + + /** + * Get the latest registered users. + * + * @return DataCollection + */ + public function getLatestUsers(): DataCollection + { + $users = User::query() + ->with(['roles']) + ->orderBy('created_at', 'desc') + ->take(5) + ->get(); + + $mappedUsers = $users->map(function ($user) { + /** @var User $user */ + /** @var \Illuminate\Database\Eloquent\Collection $roles */ + $roles = $user->roles; + + return new LatestUserData( + name: $user->name, + email: $user->email, + initials: $user->initials(), + registeredAt: $user->created_at ? $user->created_at->diffForHumans() : 'unknown', + roles: RoleData::collect($roles->map(fn ($role) => new RoleData( + name: $role->name, + users: UserData::collect([], DataCollection::class), + services: ServiceData::collect([], DataCollection::class), + status: RolesStatusEnum::Active, + )), DataCollection::class), + ); + }); + + return LatestUserData::collect($mappedUsers, DataCollection::class); + } + + /** + * Get all user role and service assignments. + * + * @return DataCollection + */ + public function getUserAssignments(): DataCollection + { + $users = User::query() + ->with(['roles.apps']) + ->orderBy('id') + ->get(); + + $mappedUsers = $users->map(function ($user) { + /** @var User $user */ + /** @var \Illuminate\Database\Eloquent\Collection $roles */ + $roles = $user->roles; + + return new UserData( + name: $user->name, + email: $user->email, + active: true, + roles: RoleData::collect($roles->map(function ($role) { + /** @var Role $role */ + /** @var \Illuminate\Database\Eloquent\Collection $apps */ + $apps = $role->apps; + + return new RoleData( + name: $role->name, + users: UserData::collect([], DataCollection::class), + services: ServiceData::collect($apps->map(fn ($app) => new ServiceData( + name: $app->name, + )), DataCollection::class), + status: RolesStatusEnum::Active, + ); + }), DataCollection::class), + ); + }); + + return UserData::collect($mappedUsers, DataCollection::class); + } +} diff --git a/app/Services/SamlIdpService.php b/app/Services/SamlIdpService.php index 52fc2cc..9d36721 100644 --- a/app/Services/SamlIdpService.php +++ b/app/Services/SamlIdpService.php @@ -25,13 +25,25 @@ class SamlIdpService */ public function parseRequest(string $samlRequest): array { + Log::debug('SamlIdpService: Parsing incoming SAMLRequest.', [ + 'payload_length' => mb_strlen($samlRequest), + ]); + $decoded = base64_decode($samlRequest, true); if (false === $decoded) { + Log::error('SamlIdpService: Failed to base64 decode SAMLRequest.'); throw new Exception('Failed to base64 decode SAMLRequest.'); } // Try deflated (GET Redirect binding), fallback to raw XML (POST binding) - $xml = @gzinflate($decoded) ?: $decoded; + $inflated = @gzinflate($decoded); + if (false !== $inflated) { + Log::debug('SamlIdpService: SAMLRequest successfully inflated using gzip/deflate.'); + $xml = $inflated; + } else { + Log::debug('SamlIdpService: SAMLRequest gzip inflation failed/skipped; falling back to raw XML.'); + $xml = $decoded; + } $dom = new DOMDocument(); $dom->preserveWhiteSpace = false; @@ -39,9 +51,15 @@ public function parseRequest(string $samlRequest): array // Securely handle XML loading without using '@' suppression libxml_use_internal_errors(true); $loaded = $dom->loadXML($xml); + $xmlErrors = libxml_get_errors(); libxml_clear_errors(); if (! $loaded) { + $formattedErrors = array_map(fn ($err) => mb_trim($err->message), $xmlErrors); + Log::error('SamlIdpService: Failed to load SAMLRequest XML.', [ + 'errors' => $formattedErrors, + 'xml_snippet' => mb_substr($xml, 0, 500), + ]); throw new Exception('Failed to load SAMLRequest XML.'); } @@ -52,6 +70,12 @@ public function parseRequest(string $samlRequest): array $requestId = $dom->documentElement->getAttribute('ID'); $acsUrl = $dom->documentElement->getAttribute('AssertionConsumerServiceURL'); + Log::info('SamlIdpService: SAMLRequest XML parsed successfully.', [ + 'requestId' => $requestId, + 'issuer' => $issuer, + 'acsUrl' => $acsUrl, + ]); + return [ 'requestId' => $requestId, 'issuer' => $issuer, @@ -64,9 +88,23 @@ public function parseRequest(string $samlRequest): array */ public function findConnectedApp(string $issuer): ?ConnectedApp { - return ConnectedApp::query() + Log::debug('SamlIdpService: Querying ConnectedApp database for entity ID.', ['issuer' => $issuer]); + $app = ConnectedApp::query() ->where('settings->saml->entity_id', $issuer) ->first(); + + if ($app) { + Log::info('SamlIdpService: ConnectedApp found for issuer.', [ + 'issuer' => $issuer, + 'app_id' => $app->id, + 'app_name' => $app->name, + 'status' => $app->status->name ?? 'unknown', + ]); + } else { + Log::warning('SamlIdpService: No ConnectedApp found matching issuer entity ID.', ['issuer' => $issuer]); + } + + return $app; } /** @@ -74,10 +112,14 @@ public function findConnectedApp(string $issuer): ?ConnectedApp */ public function generateMetadata(string $idpEntityId, string $ssoUrl): string { + Log::info('SamlIdpService: Generating SAML IdP metadata XML.', [ + 'idpEntityId' => $idpEntityId, + 'ssoUrl' => $ssoUrl, + ]); $certPem = $this->getPublicCertificate(); $certClean = $this->cleanPem($certPem); - return mb_trim(<< @@ -93,6 +135,12 @@ public function generateMetadata(string $idpEntityId, string $ssoUrl): string XML); + + Log::info('SamlIdpService: SAML IdP metadata XML successfully generated.', [ + 'length' => mb_strlen($metadata), + ]); + + return $metadata; } /** @@ -101,10 +149,20 @@ public function generateMetadata(string $idpEntityId, string $ssoUrl): string private function getPublicCertificate(): string { $path = storage_path('app/saml/idp.crt'); + Log::debug('SamlIdpService: Loading public certificate.', ['path' => $path]); try { - return File::get($path); + $cert = File::get($path); + Log::debug('SamlIdpService: Public certificate loaded successfully.', [ + 'path' => $path, + 'length' => mb_strlen($cert), + ]); + + return $cert; } catch (Exception $e) { - Log::error("SAML public certificate not found at [$path].", ['message' => $e->getMessage()]); + Log::error("SamlIdpService: SAML public certificate not found at [$path].", [ + 'path' => $path, + 'message' => $e->getMessage(), + ]); throw new RuntimeException("SAML public certificate not found at [$path].", code: 500); } } @@ -139,6 +197,18 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI $responseId = '_'.Str::uuid()->toString(); $assertionId = '_'.Str::uuid()->toString(); + Log::info('SamlIdpService: Initiating SAML Response generation.', [ + 'user_id' => $user->id, + 'user_email' => $user->email, + 'app_id' => $app->id, + 'app_name' => $app->name, + 'sp_entity_id' => $spEntityId, + 'request_id' => $requestId, + 'acs_url' => $acsUrl, + 'response_id' => $responseId, + 'assertion_id' => $assertionId, + ]); + // Prepare Timestamps $now = time(); $timestamps = [ @@ -147,10 +217,13 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI 'expire' => gmdate("Y-m-d\TH:i:s\Z", $now + 300), ]; + Log::debug('SamlIdpService: Computed SAML assertion timestamps.', $timestamps); + $dom = new DOMDocument('1.0', 'UTF-8'); $dom->preserveWhiteSpace = false; // 1. Create Base Response + Log::debug('SamlIdpService: Constructing samlp:Response element.'); $response = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:protocol', 'samlp:Response'); $response->setAttribute('ID', $responseId); $response->setAttribute('InResponseTo', $requestId); @@ -171,6 +244,7 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI $response->appendChild($status); // 2. Create Assertion + Log::debug('SamlIdpService: Constructing saml:Assertion element.'); $assertion = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Assertion'); $assertion->setAttribute('ID', $assertionId); $assertion->setAttribute('Version', '2.0'); @@ -178,6 +252,7 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI $assertion->appendChild($dom->createElement('saml:Issuer', $idpEntityId)); // 3. Build Assertion Sub-components + Log::debug('SamlIdpService: Appending assertion components (Subject, Conditions, AuthnStatement, Attributes).'); $this->buildSubject($dom, $assertion, $user, $requestId, $acsUrl, $timestamps['expire']); $this->buildConditions($dom, $assertion, $spEntityId, $timestamps['notBefore'], $timestamps['expire']); $this->buildAuthnStatement($dom, $assertion, $timestamps['issue'], $assertionId); @@ -187,9 +262,20 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI $dom->appendChild($response); // 4. Sign Assertion & Return Base64 payload + Log::info('SamlIdpService: Invoking cryptographic signing for the Assertion node.'); $this->signAssertion($dom, $assertion); - return base64_encode($dom->saveXML()); + $xml = $dom->saveXML(); + Log::debug('SamlIdpService: SAML Response XML generated successfully.', [ + 'xml_length' => mb_strlen($xml), + ]); + + $base64Response = base64_encode($xml); + Log::info('SamlIdpService: SAML Response base64 encoded successfully.', [ + 'base64_length' => mb_strlen($base64Response), + ]); + + return $base64Response; } /** @@ -207,6 +293,13 @@ private function buildSubject( ): void { $immutableId = $user->immutable_id ?? base64_encode((string) $user->id); + Log::debug('SamlIdpService: Constructing saml:Subject component.', [ + 'user_id' => $user->id, + 'has_explicit_immutable_id' => ! empty($user->immutable_id), + 'resolved_immutable_id' => $immutableId, + 'nameid_format' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + ]); + $subject = $dom->createElement('saml:Subject'); $nameId = $dom->createElement('saml:NameID', $immutableId); $nameId->setAttribute('Format', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'); @@ -237,6 +330,12 @@ private function buildConditions( string $notBefore, string $expireInstant ): void { + Log::debug('SamlIdpService: Constructing saml:Conditions component.', [ + 'sp_entity_id' => $spEntityId, + 'not_before' => $notBefore, + 'not_on_or_after' => $expireInstant, + ]); + $conditions = $dom->createElement('saml:Conditions'); $conditions->setAttribute('NotBefore', $notBefore); $conditions->setAttribute('NotOnOrAfter', $expireInstant); @@ -259,6 +358,12 @@ private function buildAuthnStatement( string $issueInstant, string $assertionId ): void { + Log::debug('SamlIdpService: Constructing saml:AuthnStatement component.', [ + 'authn_instant' => $issueInstant, + 'session_index' => $assertionId, + 'authn_class' => 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', + ]); + $authnStatement = $dom->createElement('saml:AuthnStatement'); $authnStatement->setAttribute('AuthnInstant', $issueInstant); $authnStatement->setAttribute('SessionIndex', $assertionId); @@ -290,6 +395,12 @@ private function buildAttributeStatement(DOMDocument $dom, DOMElement $assertion 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' => $user->email, ]; + Log::debug('SamlIdpService: Constructing saml:AttributeStatement (claims mapping).', [ + 'user_id' => $user->id, + 'claims_count' => count($claims), + 'claims' => array_keys($claims), + ]); + foreach ($claims as $name => $value) { $this->addAttribute($dom, $statement, $name, (string) $value); } @@ -304,6 +415,11 @@ private function buildAttributeStatement(DOMDocument $dom, DOMElement $assertion */ private function addAttribute(DOMDocument $dom, DOMElement $statement, string $name, string $value): void { + Log::debug('SamlIdpService: Mapping claim attribute.', [ + 'name' => $name, + 'value' => $value, + ]); + $attribute = $dom->createElement('saml:Attribute'); $attribute->setAttribute('Name', $name); $attribute->setAttribute('NameFormat', 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic'); @@ -318,14 +434,22 @@ private function addAttribute(DOMDocument $dom, DOMElement $statement, string $n */ private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $assertion): void { + Log::info('SamlIdpService: Starting cryptographic assertion signing.', [ + 'assertion_id' => $assertion->getAttribute('ID'), + ]); + try { $privateKeyPem = $this->getPrivateKey(); $certPem = $this->getPublicCertificate(); + Log::debug('SamlIdpService: Instantiating XMLSecurityDSig for Assertion.'); $objDSig = new XMLSecurityDSig(); $objDSig->setCanonicalMethod(XMLSecurityDSig::C14N); $assertion->setIdAttribute('ID', true); + Log::debug('SamlIdpService: Adding reference to XMLSignature.', [ + 'algorithm' => 'SHA256', + ]); $objDSig->addReference( $assertion, XMLSecurityDSig::SHA256, @@ -336,13 +460,18 @@ private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $asserti ['id_name' => 'ID'] ); + Log::debug('SamlIdpService: Loading RSA-SHA256 private key.'); $objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']); $objKey->loadKey($privateKeyPem); + Log::debug('SamlIdpService: Computing signature value.'); $objDSig->sign($objKey, $assertion); + + Log::debug('SamlIdpService: Appending X509 certificate to signature.'); $objDSig->add509Cert($certPem); // Enforce SAML Schema order compliance: Signature node must precede Subject. + Log::debug('SamlIdpService: Ordering DOM nodes. Moving Signature before Subject to satisfy SAML XSD schema.'); $signatureNode = $assertion->getElementsByTagNameNS( 'http://www.w3.org/2000/09/xmldsig#', 'Signature' @@ -354,9 +483,18 @@ private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $asserti if ($signatureNode && $subjectNode) { $assertion->insertBefore($signatureNode, $subjectNode); + Log::debug('SamlIdpService: Re-ordered nodes: Signature is now placed before Subject.'); + } else { + Log::warning('SamlIdpService: Failed to re-order nodes; Signature or Subject node not found.', [ + 'signature_found' => ! empty($signatureNode), + 'subject_found' => ! empty($subjectNode), + ]); } } catch (Exception $e) { - Log::error('SAML assertion signing failed.', ['message' => $e->getMessage()]); + Log::error('SamlIdpService: Cryptographic signing failed.', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); throw new RuntimeException('SAML assertion signing failed.', code: 500); } } @@ -369,10 +507,20 @@ private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $asserti private function getPrivateKey(): string { $path = storage_path('app/saml/idp.key'); + Log::debug('SamlIdpService: Loading private key.', ['path' => $path]); try { - return File::get($path); + $key = File::get($path); + Log::debug('SamlIdpService: Private key loaded successfully.', [ + 'path' => $path, + 'length' => mb_strlen($key), + ]); + + return $key; } catch (Exception $e) { - Log::error("SAML private key not found at [$path].", ['message' => $e->getMessage()]); + Log::error("SamlIdpService: SAML private key not found at [$path].", [ + 'path' => $path, + 'message' => $e->getMessage(), + ]); throw new RuntimeException("SAML private key not found at [$path].", code: 500); } } diff --git a/resources/views/components/dashboard-stats.blade.php b/resources/views/components/dashboard-stats.blade.php index 5925d3c..d91972a 100644 --- a/resources/views/components/dashboard-stats.blade.php +++ b/resources/views/components/dashboard-stats.blade.php @@ -1,18 +1,20 @@ +@props(['stats']) +

Total Users

-

284

+

{{ $stats->totalUsers }}

Active Roles

-

4

+

{{ $stats->activeRoles }}

Services

-

8

+

{{ $stats->servicesCount }}

Revoked Today

-

7

+

{{ $stats->revokedTodayCount }}

diff --git a/resources/views/components/dashboard/connected-apps/⚡all.blade.php b/resources/views/components/dashboard/connected-apps/⚡all.blade.php index 0a7d236..6003add 100644 --- a/resources/views/components/dashboard/connected-apps/⚡all.blade.php +++ b/resources/views/components/dashboard/connected-apps/⚡all.blade.php @@ -12,6 +12,13 @@ use Spatie\LaravelData\PaginatedDataCollection; new class extends Component { + private ConnectedAppService $service; + + public function boot(ConnectedAppService $service): void + { + $this->service = $service; + } + public function mount(): void { } @@ -22,8 +29,7 @@ public function mount(): void #[Computed] public function getConnectedApps(): PaginatedDataCollection { - $service = app(ConnectedAppService::class); - return $service->getAll(); + return $this->service->getAll(); } }; ?> diff --git a/resources/views/components/dashboard/roles/⚡hr-permission.blade.php b/resources/views/components/dashboard/roles/⚡hr-permission.blade.php deleted file mode 100644 index 8fe8c2a..0000000 --- a/resources/views/components/dashboard/roles/⚡hr-permission.blade.php +++ /dev/null @@ -1,60 +0,0 @@ -header = TableHeader::collect([ - new TableHeader('name', 'Permission'), - new TableHeader('view', 'View'), - new TableHeader('create', 'Create'), - new TableHeader('update', 'Edit'), - new TableHeader('delete', 'Delete'), - ], DataCollection::class); - - $this->rolePermissions = PermissionData::collect([ - new PermissionData('Outlook - emails', true, true, false, false), - new PermissionData('Teams - Messages', true, false, true, true), - ], DataCollection::class); - } -}; -?> - -
- - - - {{ __('Revoke All') }} - - - {{ __('Save') }} - - - - - @scope('cell_view', $row) - - @endscope - @scope('cell_create', $row) - - @endscope - @scope('cell_update', $row) - - @endscope - @scope('cell_delete', $row) - - @endscope - - -
diff --git a/resources/views/components/dashboard/roles/⚡manager.blade.php b/resources/views/components/dashboard/roles/⚡manager.blade.php index a5a91c8..aa6ddc4 100644 --- a/resources/views/components/dashboard/roles/⚡manager.blade.php +++ b/resources/views/components/dashboard/roles/⚡manager.blade.php @@ -1,9 +1,9 @@ service = $service; + } + + /** + * Get roles for the manager. + * + * @return DataCollection + */ + #[Computed] + public function roles(): DataCollection + { + return $this->service->getRoles(); + } public function mount(): void { @@ -35,38 +50,6 @@ public function mount(): void "label" => "Status", ], ], DataCollection::class); - $this->roles = RoleData::collect( - [ - new RoleData( - name: "Super Admin", - - users: UserData::collect( - [ - new UserData( - name: "Kushal Saha", - email: "kushal@example.com", - ), - new UserData( - name: "Test Example", - email: "test@example.com", - ), - ], - DataCollection::class, - ), - - services: ServiceData::collect( - [ - new ServiceData(name: "Teams"), - new ServiceData(name: "Outlook"), - ], - DataCollection::class, - ), - - status: RolesStatusEnum::Active, - ), - ], - DataCollection::class, - ); } }; ?> @@ -80,7 +63,7 @@ public function mount(): void - + @scope('cell_users', $role) @php /** @var RoleData $role */ @endphp
diff --git a/resources/views/components/dashboard/users/⚡assignments.blade.php b/resources/views/components/dashboard/users/⚡assignments.blade.php index 52fd50d..1f696a8 100644 --- a/resources/views/components/dashboard/users/⚡assignments.blade.php +++ b/resources/views/components/dashboard/users/⚡assignments.blade.php @@ -2,9 +2,8 @@ use App\Data\Ui\TableHeader; use App\Data\Users\UserData; -use App\Data\Ui\ManageRoles\RoleData; -use App\Data\Ui\ManageRoles\ServiceData; -use App\Enums\RolesStatusEnum; +use App\Services\AdminDashboardService; +use Livewire\Attributes\Computed; use Livewire\Component; use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\DataCollection; @@ -14,9 +13,23 @@ #[DataCollectionOf(TableHeader::class)] public DataCollection $header; - /** @var DataCollection|null */ - #[DataCollectionOf(UserData::class)] - public ?DataCollection $users = null; + private AdminDashboardService $service; + + public function boot(AdminDashboardService $service): void + { + $this->service = $service; + } + + /** + * Get user assignments. + * + * @return DataCollection + */ + #[Computed] + public function users(): DataCollection + { + return $this->service->getUserAssignments(); + } public function mount(): void { @@ -26,66 +39,6 @@ public function mount(): void new TableHeader('role.service', 'Service Access'), new TableHeader('status', 'Status'), ], DataCollection::class); - - $this->users = UserData::collect([ - new UserData( - name: 'Priya Rao', - email: 'priya@example.com', - active: true, - roles: RoleData::collect([ - new RoleData( - name: 'Super Admin', - users: UserData::collect([], DataCollection::class), - services: ServiceData::collect([ - new ServiceData(name: 'AWS Core'), - new ServiceData(name: 'GitHub Enterprise'), - ], DataCollection::class), - status: RolesStatusEnum::Active, - ), - new RoleData( - name: 'HR Manager', - users: UserData::collect([], DataCollection::class), - services: ServiceData::collect([ - new ServiceData(name: 'Keka'), - new ServiceData(name: 'Outlook'), - ], DataCollection::class), - status: RolesStatusEnum::Active, - ) - ], DataCollection::class) - ), - - new UserData( - name: 'Kushal Saha', - email: 'kushal@example.com', - active: true, - roles: RoleData::collect([ - new RoleData( - name: 'Backend Developer', - users: UserData::collect([], DataCollection::class), - services: ServiceData::collect([ - new ServiceData(name: 'Forge'), - ], DataCollection::class), - status: RolesStatusEnum::Active, - ) - ], DataCollection::class) - ), - - new UserData( - name: 'Alex Johnson', - email: 'alex@example.com', - active: false, - roles: RoleData::collect([ - new RoleData( - name: 'Guest Viewer', - users: UserData::collect([], DataCollection::class), - services: ServiceData::collect([ - new ServiceData(name: 'Analytics Dashboard'), - ], DataCollection::class), - status: RolesStatusEnum::Active, - ) - ], DataCollection::class) - ) - ], DataCollection::class); } }; ?> @@ -98,7 +51,7 @@ public function mount(): void - + @scope('cell_name', $row) @endscope diff --git a/resources/views/components/dashboard/users/⚡latest.blade.php b/resources/views/components/dashboard/users/⚡latest.blade.php new file mode 100644 index 0000000..47ac161 --- /dev/null +++ b/resources/views/components/dashboard/users/⚡latest.blade.php @@ -0,0 +1,71 @@ + */ + #[DataCollectionOf(TableHeader::class)] + public DataCollection $header; + + private AdminDashboardService $service; + + public function boot(AdminDashboardService $service): void + { + $this->service = $service; + } + + /** + * Get the latest users. + * + * @return DataCollection + */ + #[Computed] + public function users(): DataCollection + { + return $this->service->getLatestUsers(); + } + + public function mount(): void + { + $this->header = TableHeader::collect([ + new TableHeader('name', 'User'), + new TableHeader('registeredAt', 'Registered'), + new TableHeader('roles', 'Roles'), + ], DataCollection::class); + } +}; +?> + +
+ + + @scope('cell_name', $row) + + @endscope + + @scope('cell_registeredAt', $row) + + {{ $row->registeredAt }} + + @endscope + + @scope('cell_roles', $row) +
+ @forelse ($row->roles as $role) + + {{ $role->name }} + + @empty + No roles assigned + @endforelse +
+ @endscope +
+
+
diff --git a/resources/views/pages/dashboards/⚡admin.blade.php b/resources/views/pages/dashboards/⚡admin.blade.php index 5864e6f..311b78c 100644 --- a/resources/views/pages/dashboards/⚡admin.blade.php +++ b/resources/views/pages/dashboards/⚡admin.blade.php @@ -1,6 +1,8 @@ service = $service; + } + + #[Computed] + public function stats(): DashboardStatsData + { + return $this->service->getStats(); + } }; ?>
- + - +
diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index fbea51e..f0fc6cf 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -2,17 +2,47 @@ declare(strict_types=1); -use App\Models\User; +use App\Models\{Role, User}; test('guests are redirected to the login page', function (): void { $response = $this->get(route('dashboard')); $response->assertRedirect(route('login')); }); -test('authenticated users can visit the dashboard', function (): void { +test('authenticated users without admin roles are redirected to the user dashboard', function (): void { $user = User::factory()->create(); $this->actingAs($user); $response = $this->get(route('dashboard')); + $response->assertRedirect(route('dashboard.user')); +}); + +test('authenticated admin users are redirected to the admin dashboard', function (): void { + Role::findOrCreate('admin', 'web'); + $user = User::factory()->create(); + $user->assignRole('admin'); + $this->actingAs($user); + + $response = $this->get(route('dashboard')); + $response->assertRedirect(route('dashboard.admin')); +}); + +test('authenticated users can visit the user dashboard', function (): void { + Role::findOrCreate('user', 'web'); + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $response = $this->get(route('dashboard.user')); + $response->assertOk(); +}); + +test('authenticated admin users can visit the admin dashboard', function (): void { + Role::findOrCreate('admin', 'web'); + $user = User::factory()->create(); + $user->assignRole('admin'); + $this->actingAs($user); + + $response = $this->get(route('dashboard.admin')); $response->assertOk(); });