feat: harden identity and recovery operations
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Identity\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class MicrosoftAuthenticationException extends RuntimeException {}
|
||||
45
app/Domain/Identity/Models/ExternalIdentity.php
Normal file
45
app/Domain/Identity/Models/ExternalIdentity.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
165
app/Domain/Identity/Services/MicrosoftIdentityService.php
Normal file
165
app/Domain/Identity/Services/MicrosoftIdentityService.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user