auth tests

This commit is contained in:
Giuseppe Naponiello
2026-06-22 14:31:44 +02:00
parent 34a8ebc6fb
commit cc4e174880
75 changed files with 4679 additions and 168 deletions

View 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']),
]);
}
}

View 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'];
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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;
}
}

View 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.');
}
}

View 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'));
}
}

View 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);
});
}
}

View 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(),
]);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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'],
];
}
}

View 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',
];
}
}

View 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'],
];
}
}

View 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);
}
}

View 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();
}
}

View File

@@ -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';
}
}

View 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 [
//
];
}
}

View 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 [
//
];
}
}

View File

@@ -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()));
}
}

View 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()
);
});
}
}