45
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Valide les jetons d'accès Entra envoyés par l'add-in (obtenus par NAA).
|
||||
* Contrôles : signature (clés publiques Entra), expiration, audience, émetteur,
|
||||
* tenant autorisé et actif, scope attendu.
|
||||
*/
|
||||
class EntraTokenValidator
|
||||
{
|
||||
/** @return array<string, mixed> claims du jeton */
|
||||
public function validate(string $jwt): array
|
||||
{
|
||||
JWT::$leeway = (int) config('signature.addin.leeway_seconds', 120);
|
||||
|
||||
try {
|
||||
$claims = (array) JWT::decode($jwt, $this->keys());
|
||||
} catch (Throwable $e) {
|
||||
// Rotation des clés Entra : un seul nouvel essai avec des clés fraîches.
|
||||
Cache::forget('entra-jwks');
|
||||
try {
|
||||
$claims = (array) JWT::decode($jwt, $this->keys());
|
||||
} catch (Throwable) {
|
||||
throw new RuntimeException('Jeton invalide : '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$tid = $claims['tid'] ?? null;
|
||||
if (! $tid || ! Tenant::where('entra_tenant_id', $tid)->where('active', true)->exists()) {
|
||||
throw new RuntimeException('Tenant non autorisé');
|
||||
}
|
||||
|
||||
$issuers = ["https://login.microsoftonline.com/{$tid}/v2.0", "https://sts.windows.net/{$tid}/"];
|
||||
if (! in_array($claims['iss'] ?? null, $issuers, true)) {
|
||||
throw new RuntimeException('Émetteur inattendu');
|
||||
}
|
||||
|
||||
$audiences = (array) config('signature.addin.audiences');
|
||||
if (! $audiences || ! in_array($claims['aud'] ?? null, $audiences, true)) {
|
||||
throw new RuntimeException('Audience inattendue : reçue « '.($claims['aud'] ?? 'aucune').' », attendue(s) « '.implode(', ', $audiences).' »');
|
||||
}
|
||||
|
||||
$scopes = explode(' ', (string) ($claims['scp'] ?? ''));
|
||||
if (! in_array(config('signature.addin.required_scope'), $scopes, true)) {
|
||||
throw new RuntimeException('Scope manquant : reçu « '.($claims['scp'] ?? 'aucun').' »');
|
||||
}
|
||||
|
||||
if (empty($claims['oid'])) {
|
||||
throw new RuntimeException('Identifiant utilisateur absent');
|
||||
}
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
private function keys(): array
|
||||
{
|
||||
$jwks = Cache::remember('entra-jwks', config('signature.addin.jwks_cache_seconds', 86400), function () {
|
||||
$res = Http::timeout(10)->get('https://login.microsoftonline.com/common/discovery/v2.0/keys');
|
||||
if (! $res->successful()) {
|
||||
throw new RuntimeException('Clés Entra indisponibles');
|
||||
}
|
||||
|
||||
return $res->json();
|
||||
});
|
||||
|
||||
return JWK::parseKeySet($jwks, 'RS256');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Graph;
|
||||
|
||||
use App\Models\DirectoryGroup;
|
||||
use App\Models\DirectoryUser;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Réplique utilisateurs et groupes Entra dans la base locale.
|
||||
* - Utilisateurs : requête delta (incrémentale), complète si --full ou si le deltaLink a expiré.
|
||||
* - Groupes : ceux dont le nom commence par le préfixe configuré, avec appartenance transitive.
|
||||
*/
|
||||
class DirectorySync
|
||||
{
|
||||
private const USER_SELECT = 'id,displayName,givenName,surname,jobTitle,department,companyName,officeLocation,businessPhones,mobilePhone,mail,userPrincipalName,streetAddress,postalCode,city,accountEnabled';
|
||||
|
||||
public function run(Tenant $tenant, bool $full = false): array
|
||||
{
|
||||
$graph = new GraphClient($tenant->entra_tenant_id);
|
||||
$stats = ['users' => 0, 'removed' => 0, 'groups' => 0, 'memberships' => 0];
|
||||
|
||||
try {
|
||||
[$stats['users'], $stats['removed']] = $this->syncUsers($tenant, $graph, $full);
|
||||
[$stats['groups'], $stats['memberships']] = $this->syncGroups($tenant, $graph);
|
||||
$tenant->update(['last_sync_at' => now(), 'last_sync_status' => 'ok']);
|
||||
} catch (Throwable $e) {
|
||||
$tenant->update(['last_sync_at' => now(), 'last_sync_status' => 'erreur : '.mb_substr($e->getMessage(), 0, 200)]);
|
||||
Log::error('Synchro annuaire en échec', ['tenant' => $tenant->id, 'error' => $e->getMessage()]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private function syncUsers(Tenant $tenant, GraphClient $graph, bool $full): array
|
||||
{
|
||||
$start = (! $full && $tenant->users_delta_link)
|
||||
? $tenant->users_delta_link
|
||||
: '/users/delta?$select='.self::USER_SELECT;
|
||||
|
||||
try {
|
||||
[$items, $deltaLink] = $graph->all($start);
|
||||
} catch (Throwable $e) {
|
||||
if ($full || ! $tenant->users_delta_link) {
|
||||
throw $e;
|
||||
}
|
||||
// deltaLink expiré (410) ou invalide : on repart d'une synchro complète.
|
||||
[$items, $deltaLink] = $graph->all('/users/delta?$select='.self::USER_SELECT);
|
||||
$full = true;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$upserted = 0;
|
||||
$removed = 0;
|
||||
$seen = [];
|
||||
|
||||
foreach ($items as $u) {
|
||||
if (isset($u['@removed'])) {
|
||||
$removed += DirectoryUser::where('tenant_id', $tenant->id)->where('entra_oid', $u['id'])->delete();
|
||||
continue;
|
||||
}
|
||||
// En delta, seules les propriétés modifiées sont renvoyées : on ne met à jour que celles-là.
|
||||
$attrs = array_filter([
|
||||
'display_name' => $u['displayName'] ?? null,
|
||||
'given_name' => $u['givenName'] ?? null,
|
||||
'surname' => $u['surname'] ?? null,
|
||||
'job_title' => $u['jobTitle'] ?? null,
|
||||
'department' => $u['department'] ?? null,
|
||||
'company_name' => $u['companyName'] ?? null,
|
||||
'office_location' => $u['officeLocation'] ?? null,
|
||||
'business_phone' => array_key_exists('businessPhones', $u) ? ($u['businessPhones'][0] ?? null) : null,
|
||||
'mobile_phone' => $u['mobilePhone'] ?? null,
|
||||
'mail' => isset($u['mail']) ? strtolower($u['mail']) : null,
|
||||
'user_principal_name' => isset($u['userPrincipalName']) ? strtolower($u['userPrincipalName']) : null,
|
||||
'street_address' => $u['streetAddress'] ?? null,
|
||||
'postal_code' => $u['postalCode'] ?? null,
|
||||
'city' => $u['city'] ?? null,
|
||||
], fn ($v, $k) => array_key_exists($this->graphKey($k), $u), ARRAY_FILTER_USE_BOTH);
|
||||
if (array_key_exists('accountEnabled', $u)) {
|
||||
$attrs['account_enabled'] = (bool) $u['accountEnabled'];
|
||||
}
|
||||
$attrs['synced_at'] = $now;
|
||||
|
||||
$user = DirectoryUser::withTrashed()->firstOrNew(['tenant_id' => $tenant->id, 'entra_oid' => $u['id']]);
|
||||
$user->fill($attrs);
|
||||
$user->deleted_at = null;
|
||||
$user->save();
|
||||
$seen[] = $u['id'];
|
||||
$upserted++;
|
||||
}
|
||||
|
||||
if ($full) {
|
||||
// Synchro complète : tout ce qui n'a pas été vu n'existe plus dans Entra.
|
||||
$removed += DirectoryUser::where('tenant_id', $tenant->id)->whereNotIn('entra_oid', $seen)->delete();
|
||||
}
|
||||
|
||||
$tenant->users_delta_link = $deltaLink;
|
||||
$tenant->save();
|
||||
|
||||
return [$upserted, $removed];
|
||||
}
|
||||
|
||||
private function syncGroups(Tenant $tenant, GraphClient $graph): array
|
||||
{
|
||||
$prefix = str_replace("'", "''", config('signature.graph.group_prefix'));
|
||||
[$groups] = $graph->all("/groups?\$filter=startswith(displayName,'{$prefix}')&\$select=id,displayName");
|
||||
|
||||
$memberships = 0;
|
||||
$keep = [];
|
||||
foreach ($groups as $g) {
|
||||
$group = DirectoryGroup::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'entra_oid' => $g['id']],
|
||||
['display_name' => $g['displayName'], 'synced_at' => now()],
|
||||
);
|
||||
$keep[] = $group->id;
|
||||
|
||||
[$members] = $graph->all(
|
||||
"/groups/{$g['id']}/transitiveMembers/microsoft.graph.user?\$select=id&\$count=true&\$top=999",
|
||||
['ConsistencyLevel' => 'eventual'],
|
||||
);
|
||||
$userIds = DirectoryUser::where('tenant_id', $tenant->id)
|
||||
->whereIn('entra_oid', array_column($members, 'id'))
|
||||
->pluck('id')->all();
|
||||
|
||||
DB::transaction(fn () => $group->members()->sync($userIds));
|
||||
$memberships += count($userIds);
|
||||
}
|
||||
|
||||
DirectoryGroup::where('tenant_id', $tenant->id)->whereNotIn('id', $keep)->delete();
|
||||
|
||||
return [count($groups), $memberships];
|
||||
}
|
||||
|
||||
private function graphKey(string $column): string
|
||||
{
|
||||
return [
|
||||
'display_name' => 'displayName', 'given_name' => 'givenName', 'surname' => 'surname',
|
||||
'job_title' => 'jobTitle', 'department' => 'department', 'company_name' => 'companyName',
|
||||
'office_location' => 'officeLocation', 'business_phone' => 'businessPhones',
|
||||
'mobile_phone' => 'mobilePhone', 'mail' => 'mail', 'user_principal_name' => 'userPrincipalName',
|
||||
'street_address' => 'streetAddress', 'postal_code' => 'postalCode', 'city' => 'city',
|
||||
][$column];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Graph;
|
||||
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Client Microsoft Graph minimal (flux client credentials, permissions « application »).
|
||||
* Permissions requises, avec consentement administrateur : User.Read.All, GroupMember.Read.All.
|
||||
*/
|
||||
class GraphClient
|
||||
{
|
||||
public function __construct(private readonly string $tenantId) {}
|
||||
|
||||
public function token(): string
|
||||
{
|
||||
return Cache::remember("graph-token:{$this->tenantId}", now()->addMinutes(50), function () {
|
||||
$res = Http::asForm()->timeout(config('signature.graph.timeout'))
|
||||
->post("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", [
|
||||
'client_id' => config('signature.graph.client_id'),
|
||||
'client_secret' => config('signature.graph.client_secret'),
|
||||
'scope' => 'https://graph.microsoft.com/.default',
|
||||
'grant_type' => 'client_credentials',
|
||||
]);
|
||||
if (! $res->successful()) {
|
||||
throw new RuntimeException('Jeton Graph refusé : '.$res->json('error_description', $res->body()));
|
||||
}
|
||||
|
||||
return $res->json('access_token');
|
||||
});
|
||||
}
|
||||
|
||||
/** GET sur une URL relative (/users…) ou absolue (@odata.nextLink). */
|
||||
public function get(string $url, array $headers = []): array
|
||||
{
|
||||
$full = str_starts_with($url, 'https://') ? $url : config('signature.graph.base_url').$url;
|
||||
|
||||
$res = Http::withToken($this->token())
|
||||
->withHeaders($headers)
|
||||
->timeout(config('signature.graph.timeout'))
|
||||
// Nouvel essai sur limitation (429) ou indisponibilité temporaire de Graph.
|
||||
->retry(3, 2000, fn ($e) => $e instanceof RequestException && in_array($e->response->status(), [429, 503, 504], true), throw: false)
|
||||
->get($full);
|
||||
|
||||
if (! $res->successful()) {
|
||||
throw new RuntimeException("Graph {$res->status()} sur {$url} : ".$res->body());
|
||||
}
|
||||
|
||||
return $res->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parcourt toutes les pages d'une collection.
|
||||
* Retourne [éléments, deltaLink|null].
|
||||
*/
|
||||
public function all(string $url, array $headers = []): array
|
||||
{
|
||||
$items = [];
|
||||
$delta = null;
|
||||
$next = $url;
|
||||
while ($next) {
|
||||
$page = $this->get($next, $headers);
|
||||
array_push($items, ...($page['value'] ?? []));
|
||||
$next = $page['@odata.nextLink'] ?? null;
|
||||
$delta = $page['@odata.deltaLink'] ?? $delta;
|
||||
}
|
||||
|
||||
return [$items, $delta];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user