feat: harden identity and recovery operations

This commit is contained in:
2026-08-04 20:24:18 +01:00
parent 7a4ac48aaf
commit eae2a44f46
30 changed files with 1428 additions and 21 deletions

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Domain\Identity\Exceptions;
use RuntimeException;
final class MicrosoftAuthenticationException extends RuntimeException {}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Domain\Identity\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $user_id
* @property string $provider
* @property string $provider_subject
* @property string|null $tenant_id
* @property string $provider_email
* @property array<string, mixed>|null $claims
* @property Carbon|null $last_login_at
*/
class ExternalIdentity extends Model
{
protected $fillable = [
'user_id',
'provider',
'provider_subject',
'tenant_id',
'provider_email',
'claims',
'last_login_at',
];
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
protected function casts(): array
{
return [
'claims' => 'array',
'last_login_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Audit\Models\AuditLog;
use App\Domain\Identity\Exceptions\MicrosoftAuthenticationException;
use App\Domain\Identity\Models\ExternalIdentity;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class MicrosoftIdentityService
{
/** @param array{sub:string,oid:?string,tid:string,email:string,name:string,preferred_username:?string} $claims */
public function link(User $user, array $claims, Request $request): ExternalIdentity
{
if (! $user->active) {
throw new MicrosoftAuthenticationException('A conta local está desativada.');
}
return DB::transaction(function () use ($user, $claims, $request): ExternalIdentity {
$subjectIdentity = ExternalIdentity::query()
->where('provider', 'microsoft')
->where('provider_subject', $claims['sub'])
->lockForUpdate()
->first();
if ($subjectIdentity !== null && $subjectIdentity->user_id !== $user->id) {
throw new MicrosoftAuthenticationException('Esta identidade Microsoft já está associada a outra conta.');
}
$userIdentity = ExternalIdentity::query()
->where('provider', 'microsoft')
->where('user_id', $user->id)
->lockForUpdate()
->first();
if ($userIdentity !== null && $userIdentity->provider_subject !== $claims['sub']) {
throw new MicrosoftAuthenticationException('A conta local já tem outra identidade Microsoft associada.');
}
$identity = $subjectIdentity ?? $userIdentity ?? new ExternalIdentity([
'provider' => 'microsoft',
'provider_subject' => $claims['sub'],
]);
$identity->fill([
'user_id' => $user->id,
'tenant_id' => $claims['tid'],
'provider_email' => $claims['email'],
'claims' => $this->storedClaims($claims),
'last_login_at' => now(),
]);
$identity->save();
AuditLog::create([
'actor_id' => $user->id,
'event' => 'identity.microsoft_linked',
'auditable_type' => $user->getMorphClass(),
'auditable_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'after' => [
'provider' => 'microsoft',
'tenant_id' => $claims['tid'],
'provider_email' => $claims['email'],
],
]);
return $identity;
}, 3);
}
/** @param array{sub:string,oid:?string,tid:string,email:string,name:string,preferred_username:?string} $claims */
public function userForLogin(array $claims, Request $request): User
{
return DB::transaction(function () use ($claims, $request): User {
$identity = ExternalIdentity::query()
->where('provider', 'microsoft')
->where('provider_subject', $claims['sub'])
->lockForUpdate()
->first();
if ($identity === null) {
throw new MicrosoftAuthenticationException(
'Esta identidade ainda não está associada. Entre com a conta local e associe-a em Segurança.',
);
}
$user = User::query()->whereKey($identity->user_id)->lockForUpdate()->first();
if ($user === null || ! $user->active) {
throw new MicrosoftAuthenticationException('A conta local associada está desativada.');
}
if (! hash_equals(strtolower((string) $identity->tenant_id), strtolower($claims['tid']))) {
throw new MicrosoftAuthenticationException('A identidade não pertence ao tenant institucional associado.');
}
$identity->update([
'provider_email' => $claims['email'],
'claims' => $this->storedClaims($claims),
'last_login_at' => now(),
]);
$user->update(['last_login_at' => now()]);
AuditLog::create([
'actor_id' => $user->id,
'event' => 'identity.microsoft_login',
'auditable_type' => $user->getMorphClass(),
'auditable_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'metadata' => [
'provider' => 'microsoft',
'tenant_id' => $claims['tid'],
],
]);
return $user;
}, 3);
}
public function unlink(User $user, Request $request): void
{
DB::transaction(function () use ($user, $request): void {
$identity = ExternalIdentity::query()
->where('provider', 'microsoft')
->where('user_id', $user->id)
->lockForUpdate()
->first();
if ($identity === null) {
return;
}
$before = [
'provider' => 'microsoft',
'tenant_id' => $identity->tenant_id,
'provider_email' => $identity->provider_email,
];
$identity->delete();
AuditLog::create([
'actor_id' => $user->id,
'event' => 'identity.microsoft_unlinked',
'auditable_type' => $user->getMorphClass(),
'auditable_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'before' => $before,
]);
}, 3);
}
/** @param array{sub:string,oid:?string,tid:string,email:string,name:string,preferred_username:?string} $claims
* @return array<string, string|null>
*/
private function storedClaims(array $claims): array
{
return [
'oid' => $claims['oid'],
'name' => $claims['name'],
'preferred_username' => $claims['preferred_username'],
];
}
}