auth tests
This commit is contained in:
43
backend/app/Actions/Fortify/CreateNewUser.php
Normal file
43
backend/app/Actions/Fortify/CreateNewUser.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
19
backend/app/Actions/Fortify/PasswordValidationRules.php
Normal file
19
backend/app/Actions/Fortify/PasswordValidationRules.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
}
|
||||
32
backend/app/Actions/Fortify/ResetUserPassword.php
Normal file
32
backend/app/Actions/Fortify/ResetUserPassword.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
35
backend/app/Actions/Fortify/UpdateUserPassword.php
Normal file
35
backend/app/Actions/Fortify/UpdateUserPassword.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'current_password' => ['required', 'string', 'current_password:web'],
|
||||
'password' => $this->passwordRules(),
|
||||
], [
|
||||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||
])->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
61
backend/app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
61
backend/app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users')->ignore($user->id),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if ($input['email'] !== $user->email &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given verified user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
protected function updateVerifiedUser(User $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
}
|
||||
82
backend/app/Actions/PurgeUserAction.php
Normal file
82
backend/app/Actions/PurgeUserAction.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Exceptions\CannotDeleteSystemUserException;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Cancellazione definitiva di un utente conforme al GDPR (diritto all'oblio).
|
||||
*
|
||||
* Non rimuove la riga (va preservata l'integrità referenziale verso i contenuti
|
||||
* e la traccia di audit): i contenuti posseduti vengono riassegnati all'utente
|
||||
* di sistema e i dati personali anonimizzati in-place. Il record resta
|
||||
* soft-deleted ma, marcato `anonymized_at`, esce dal cestino e dalle liste.
|
||||
*
|
||||
* Idempotente: un utente già anonimizzato viene ignorato.
|
||||
*/
|
||||
class PurgeUserAction
|
||||
{
|
||||
/**
|
||||
* Modelli posseduti (con colonna `user_id`) da riassegnare all'utente di
|
||||
* sistema prima dell'anonimizzazione. Aggiungere qui i modelli di contenuto
|
||||
* man mano che vengono creati (devono usare SoftDeletes).
|
||||
*
|
||||
* @var list<class-string>
|
||||
*/
|
||||
private const OWNED_MODELS = [];
|
||||
|
||||
public function execute(User $user): void
|
||||
{
|
||||
if ($user->is_system) {
|
||||
throw CannotDeleteSystemUserException::make();
|
||||
}
|
||||
|
||||
if ($user->anonymized_at !== null) {
|
||||
return; // già anonimizzato: idempotente
|
||||
}
|
||||
|
||||
$system = User::where('is_system', true)->firstOrFail();
|
||||
|
||||
DB::transaction(function () use ($user, $system) {
|
||||
$this->reassignContent($user, $system);
|
||||
$this->anonymize($user);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Riassegna all'utente di sistema tutti i contenuti posseduti, inclusi
|
||||
* quelli a loro volta nel cestino.
|
||||
*/
|
||||
private function reassignContent(User $user, User $system): void
|
||||
{
|
||||
foreach (self::OWNED_MODELS as $model) {
|
||||
$model::withTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->update(['user_id' => $system->id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Azzera i dati personali mantenendo la riga per l'integrità referenziale.
|
||||
*/
|
||||
private function anonymize(User $user): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => 'Deleted user',
|
||||
'email' => "deleted-{$user->id}@anonymized.invalid",
|
||||
'password' => Hash::make(Str::random(60)),
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
'two_factor_setup_completed_at' => null,
|
||||
'email_verified_at' => null,
|
||||
'remember_token' => null,
|
||||
'must_change_password' => false,
|
||||
'anonymized_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
54
backend/app/Console/Commands/PurgeAnonymizableUsers.php
Normal file
54
backend/app/Console/Commands/PurgeAnonymizableUsers.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\PurgeUserAction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Anonimizza (GDPR) gli utenti soft-deleted oltre la retention, riassegnandone
|
||||
* i contenuti all'utente di sistema. Idempotente, pensato per esecuzione
|
||||
* schedulata giornaliera (vedi routes/console.php).
|
||||
*/
|
||||
class PurgeAnonymizableUsers extends Command
|
||||
{
|
||||
protected $signature = 'users:purge
|
||||
{--days=30 : Days kept in the trash before anonymization}
|
||||
{--dry-run : Show the affected users without anonymizing}';
|
||||
|
||||
protected $description = 'Anonymize users deleted beyond the retention window (GDPR right to be forgotten)';
|
||||
|
||||
public function handle(PurgeUserAction $purge): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$users = User::onlyTrashed()
|
||||
->where('is_system', false)
|
||||
->whereNull('anonymized_at')
|
||||
->where('deleted_at', '<=', now()->subDays($days))
|
||||
->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->info('No users to anonymize.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($users as $user) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(' [dry-run] #%d %s', $user->id, $user->email));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$purge->execute($user);
|
||||
$this->line(sprintf(' ✓ anonymized user #%d', $user->id));
|
||||
}
|
||||
|
||||
$this->info(($dryRun ? '[dry-run] ' : '').'Processed users: '.$users->count());
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
17
backend/app/Exceptions/CannotDeleteSystemUserException.php
Normal file
17
backend/app/Exceptions/CannotDeleteSystemUserException.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Sollevata quando si tenta di anonimizzare/eliminare l'utente di sistema,
|
||||
* che deve restare sempre presente come destinatario dei contenuti riassegnati.
|
||||
*/
|
||||
class CannotDeleteSystemUserException extends RuntimeException
|
||||
{
|
||||
public static function make(): self
|
||||
{
|
||||
return new self('The system user cannot be deleted.');
|
||||
}
|
||||
}
|
||||
72
backend/app/Http/Controllers/AuthController.php
Normal file
72
backend/app/Http/Controllers/AuthController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Utente autenticato corrente (include l'accessor setup_status).
|
||||
* Risposta al primo livello, come da convenzione Sanctum SPA.
|
||||
*/
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json($request->user());
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout: chiude la sessione stateful e ripulisce i cookie.
|
||||
* Sovrascrive la rotta omonima di Fortify (registrata dopo → ha precedenza).
|
||||
*/
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$this->tearDownSession($request);
|
||||
|
||||
return $this->forgetAuthCookies($this->messageResponse('auth.logout_success'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellazione self-service dell'account: soft-delete + logout.
|
||||
* L'anonimizzazione definitiva (GDPR) la fa l'Admin o la retention.
|
||||
*/
|
||||
public function destroyAccount(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_if($user->is_system, 403);
|
||||
|
||||
$user->delete();
|
||||
$this->tearDownSession($request);
|
||||
|
||||
return $this->forgetAuthCookies($this->messageResponse('account.deleted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalida la sessione corrente e rigenera il token CSRF.
|
||||
*/
|
||||
private function tearDownSession(Request $request): void
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca per la cancellazione i cookie di sessione e XSRF lato client.
|
||||
*/
|
||||
private function forgetAuthCookies(JsonResponse $response): JsonResponse
|
||||
{
|
||||
return $response
|
||||
->withCookie(Cookie::forget(config('session.cookie')))
|
||||
->withCookie(Cookie::forget('XSRF-TOKEN'));
|
||||
}
|
||||
}
|
||||
85
backend/app/Http/Controllers/UserController.php
Normal file
85
backend/app/Http/Controllers/UserController.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\IndexUserRequest;
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* Gestione utenti riservata agli amministratori (sola lettura per ora:
|
||||
* la creazione/invito e la modifica arriveranno con la gestione account).
|
||||
*
|
||||
* Ogni utente è serializzato col ruolo correlato e l'accessor `setup_status`
|
||||
* (password_required / 2fa_setup_required / complete), utile alla dashboard admin.
|
||||
*/
|
||||
class UserController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Elenco paginato degli utenti, con filtri opzionali (ricerca, ruolo, cestino).
|
||||
*/
|
||||
public function index(IndexUserRequest $request): JsonResponse
|
||||
{
|
||||
$query = User::query()
|
||||
->with('role')
|
||||
->orderBy('name');
|
||||
|
||||
$this->applyTrashed($query, $request->input('trashed'));
|
||||
$this->applySearch($query, $request->input('search'));
|
||||
|
||||
if ($request->filled('role_id')) {
|
||||
$query->where('role_id', $request->integer('role_id'));
|
||||
}
|
||||
|
||||
$users = $query->paginate($request->integer('per_page') ?: 20);
|
||||
|
||||
return $this->paginatedCollectionResponse($users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dettaglio di un singolo utente (anche cestinato/anonimizzato).
|
||||
*/
|
||||
public function show(User $user): JsonResponse
|
||||
{
|
||||
$user->load('role');
|
||||
|
||||
return $this->okResponse($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Include i soft-deleted nell'elenco: `with` (tutti) o `only` (solo cestinati).
|
||||
*
|
||||
* @param Builder<User> $query
|
||||
*/
|
||||
private function applyTrashed(Builder $query, ?string $trashed): void
|
||||
{
|
||||
match ($trashed) {
|
||||
'with' => $query->withTrashed(),
|
||||
'only' => $query->onlyTrashed(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtra per nome o email (ricerca parziale, case-insensitive).
|
||||
*
|
||||
* @param Builder<User> $query
|
||||
*/
|
||||
private function applySearch(Builder $query, ?string $search): void
|
||||
{
|
||||
if (blank($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$term = '%'.$search.'%';
|
||||
|
||||
$query->where(function (Builder $q) use ($term): void {
|
||||
$q->where('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term);
|
||||
});
|
||||
}
|
||||
}
|
||||
78
backend/app/Http/Controllers/UserRoleController.php
Normal file
78
backend/app/Http/Controllers/UserRoleController.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreUserRoleRequest;
|
||||
use App\Http\Requests\UpdateUserRoleRequest;
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\Lists\UserRole;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class UserRoleController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Elenco dei ruoli.
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return $this->collectionResponse(UserRole::all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un nuovo ruolo.
|
||||
*/
|
||||
public function store(StoreUserRoleRequest $request): JsonResponse
|
||||
{
|
||||
$role = UserRole::create($request->validated());
|
||||
|
||||
return $this->createdResponse($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dettaglio di un ruolo.
|
||||
*/
|
||||
public function show(UserRole $userRole): JsonResponse
|
||||
{
|
||||
return $this->okResponse($userRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiorna un ruolo.
|
||||
*/
|
||||
public function update(UpdateUserRoleRequest $request, UserRole $userRole): JsonResponse
|
||||
{
|
||||
$userRole->update($request->validated());
|
||||
|
||||
return $this->updatedResponse($userRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un ruolo (se non di sistema e non in uso).
|
||||
*/
|
||||
public function destroy(UserRole $userRole): JsonResponse
|
||||
{
|
||||
if ($userRole->isSystemRole()) {
|
||||
return $this->conflictResponse('Cannot delete a system role.');
|
||||
}
|
||||
|
||||
if ($userRole->isInUse()) {
|
||||
return $this->conflictResponse('Cannot delete: role assigned to at least one user.');
|
||||
}
|
||||
|
||||
$userRole->delete();
|
||||
|
||||
return $this->deletedResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indica se il ruolo è bloccato per l'eliminazione (di sistema o in uso).
|
||||
*/
|
||||
public function usage(UserRole $userRole): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'in_use' => $userRole->isSystemRole() || $userRole->isInUse(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
backend/app/Http/Middleware/EnsureSetupComplete.php
Normal file
40
backend/app/Http/Middleware/EnsureSetupComplete.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureSetupComplete
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): mixed
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$setupStatus = null;
|
||||
|
||||
if ($user->must_change_password) {
|
||||
$setupStatus = 'password_required';
|
||||
} elseif (is_null($user->two_factor_setup_completed_at)) {
|
||||
$setupStatus = '2fa_setup_required';
|
||||
}
|
||||
|
||||
if ($setupStatus !== null) {
|
||||
return response()->json([
|
||||
'setup_status' => $setupStatus,
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
22
backend/app/Http/Middleware/ForceJsonResponse.php
Normal file
22
backend/app/Http/Middleware/ForceJsonResponse.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ForceJsonResponse
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$request->headers->set('Accept', 'application/json');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
25
backend/app/Http/Middleware/IsAdmin.php
Normal file
25
backend/app/Http/Middleware/IsAdmin.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class IsAdmin
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! Auth::check() || Auth::user()->role?->name !== 'Admin') {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
31
backend/app/Http/Requests/IndexUserRequest.php
Normal file
31
backend/app/Http/Requests/IndexUserRequest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Validazione dei filtri di query per l'elenco utenti (area admin).
|
||||
* L'autorizzazione è demandata ai middleware di rotta (auth + admin + setup).
|
||||
*/
|
||||
class IndexUserRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'role_id' => ['nullable', 'integer', 'exists:user_roles,id'],
|
||||
'trashed' => ['nullable', Rule::in(['with', 'only'])],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
28
backend/app/Http/Requests/StoreUserRoleRequest.php
Normal file
28
backend/app/Http/Requests/StoreUserRoleRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreUserRoleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string|max:255|unique:user_roles,name',
|
||||
'description' => 'nullable|string',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
backend/app/Http/Requests/UpdateUserRoleRequest.php
Normal file
36
backend/app/Http/Requests/UpdateUserRoleRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateUserRoleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$id = $this->route('user_role')->id;
|
||||
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('user_roles', 'name')->ignore($id),
|
||||
],
|
||||
'description' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
130
backend/app/Http/Traits/ApiResponse.php
Normal file
130
backend/app/Http/Traits/ApiResponse.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Traits;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Risposte JSON standardizzate per i controller API.
|
||||
*
|
||||
* I messaggi sono presi da config/messages.php. Passando un $prefix si possono
|
||||
* usare messaggi specifici per risorsa (es. "user_roles.created"); senza prefix
|
||||
* si usano le chiavi generiche di primo livello.
|
||||
*/
|
||||
trait ApiResponse
|
||||
{
|
||||
/**
|
||||
* 200 — Lista paginata: { message, data, meta }.
|
||||
*/
|
||||
protected function paginatedCollectionResponse(LengthAwarePaginator $paginator, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.index" : 'messages.index'),
|
||||
'data' => $paginator->items(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 200 — Collezione di risorse.
|
||||
*/
|
||||
protected function collectionResponse(mixed $collection, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.index" : 'messages.index'),
|
||||
'data' => $collection,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 200 — Singola risorsa.
|
||||
*/
|
||||
protected function okResponse(mixed $data, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.show" : 'messages.show'),
|
||||
'data' => $data,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 201 — Risorsa creata.
|
||||
*/
|
||||
protected function createdResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.created" : 'messages.created'),
|
||||
'data' => $model,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 200 — Risorsa aggiornata.
|
||||
*/
|
||||
protected function updatedResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.updated" : 'messages.updated'),
|
||||
'data' => $model,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 200 — Risorsa eliminata.
|
||||
*/
|
||||
protected function deletedResponse(?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.deleted" : 'messages.deleted'),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 200 — Risorsa ripristinata (soft delete restore).
|
||||
*/
|
||||
protected function restoredResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.restored" : 'messages.restored'),
|
||||
'data' => $model,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 409 — Conflitto: operazione bloccata (es. risorsa in uso).
|
||||
*/
|
||||
protected function conflictResponse(string $message): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => $message,
|
||||
'in_use' => true,
|
||||
], 409);
|
||||
}
|
||||
|
||||
/**
|
||||
* Risposta con solo messaggio — chiave config es. "auth.login_success".
|
||||
*/
|
||||
protected function messageResponse(string $configKey, int $status = 200): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config("messages.{$configKey}"),
|
||||
], $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Risposta con messaggio e dati — chiave config es. "auth.login_success".
|
||||
*/
|
||||
protected function dataResponse(mixed $data, string $configKey, int $status = 200): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config("messages.{$configKey}"),
|
||||
'data' => $data,
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
57
backend/app/Models/Lists/UserRole.php
Normal file
57
backend/app/Models/Lists/UserRole.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Lists;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class UserRole extends Model implements Auditable
|
||||
{
|
||||
use AuditableTrait;
|
||||
use HasFactory;
|
||||
|
||||
public const ADMIN = 'Admin';
|
||||
|
||||
public const SUPERVISOR = 'Supervisor';
|
||||
|
||||
public const USER = 'User';
|
||||
|
||||
public const GUEST = 'Guest';
|
||||
|
||||
protected $table = 'user_roles';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* Utenti che hanno questo ruolo.
|
||||
*
|
||||
* @return HasMany<User, $this>
|
||||
*/
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class, 'role_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruolo predefinito di sistema: non eliminabile.
|
||||
*/
|
||||
public function isSystemRole(): bool
|
||||
{
|
||||
return in_array($this->name, [self::ADMIN, self::SUPERVISOR, self::USER, self::GUEST], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Il ruolo è assegnato ad almeno un utente.
|
||||
*/
|
||||
public function isInUse(): bool
|
||||
{
|
||||
return $this->users()->exists();
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,59 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use App\Models\Lists\UserRole;
|
||||
use App\Notifications\ResetPasswordNotification;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
class User extends Authenticatable implements Auditable, MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
||||
|
||||
use \OwenIt\Auditing\Auditable;
|
||||
use TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $appends = ['setup_status'];
|
||||
|
||||
protected $auditExclude = ['password', 'remember_token', 'two_factor_secret'];
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'role_id',
|
||||
'is_system',
|
||||
'must_change_password',
|
||||
'two_factor_secret',
|
||||
'two_factor_recovery_codes',
|
||||
'two_factor_setup_completed_at',
|
||||
'email_verified_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
'two_factor_secret',
|
||||
'two_factor_recovery_codes',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
@@ -27,6 +66,43 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'role_id' => 'integer',
|
||||
'must_change_password' => 'boolean',
|
||||
'two_factor_setup_completed_at' => 'datetime',
|
||||
'two_factor_confirmed_at' => 'datetime',
|
||||
'is_system' => 'boolean',
|
||||
'anonymized_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruolo applicativo dell'utente.
|
||||
*
|
||||
* @return BelongsTo<UserRole, $this>
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserRole::class, 'role_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the password reset notification.
|
||||
*/
|
||||
public function sendPasswordResetNotification($token)
|
||||
{
|
||||
$this->notify(new ResetPasswordNotification($token));
|
||||
}
|
||||
|
||||
public function getSetupStatusAttribute(): string
|
||||
{
|
||||
if ($this->must_change_password) {
|
||||
return 'password_required';
|
||||
}
|
||||
|
||||
if (is_null($this->two_factor_setup_completed_at)) {
|
||||
return '2fa_setup_required';
|
||||
}
|
||||
|
||||
return 'complete';
|
||||
}
|
||||
}
|
||||
|
||||
54
backend/app/Notifications/ResetPasswordNotification.php
Normal file
54
backend/app/Notifications/ResetPasswordNotification.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ResetPasswordNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(public readonly string $token)
|
||||
{
|
||||
// Il token viene passato al costruttore e reso disponibile come proprietà pubblica
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->line('The introduction to the notification.')
|
||||
->action('Notification Action', url('/'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
53
backend/app/Notifications/WelcomeNotification.php
Normal file
53
backend/app/Notifications/WelcomeNotification.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class WelcomeNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->line('The introduction to the notification.')
|
||||
->action('Notification Action', url('/'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -19,6 +22,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
// Rate limiter delle rotte pubbliche (gruppo `throttle:public` in routes/api.php).
|
||||
// Nessun utente loggato → si limita per IP. Tarare il numero sul traffico reale.
|
||||
RateLimiter::for('public', fn (Request $request) => Limit::perMinute(60)->by($request->ip()));
|
||||
}
|
||||
}
|
||||
|
||||
56
backend/app/Providers/FortifyServiceProvider.php
Normal file
56
backend/app/Providers/FortifyServiceProvider.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
RateLimiter::for('passkeys', function (Request $request) {
|
||||
$credentialId = $request->input('credential.id');
|
||||
|
||||
return Limit::perMinute(10)->by(
|
||||
($credentialId ?: $request->session()->getId()).'|'.$request->ip()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user