This commit is contained in:
ghassan committed 2026-08-04 11:26:25 +03:00
1 parent d0834df8df
commit 6dc35134fa
171 files changed
+19241 -49

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Actions\Auth;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginAction
{
/**
* @return array{status: 'authenticated', user: User}|array{status: 'requires_2fa', challenge_token: string}
*/
public function execute(Request $request, string $login, string $password): array
{
$throttleKey = 'login:'.$request->ip().':'.$login;
if (RateLimiter::tooManyAttempts($throttleKey, 5)) {
throw ValidationException::withMessages([
'login' => 'Too many login attempts. Please try again in a minute.',
]);
}
$user = User::where('email', $login)->orWhere('username', $login)->first();
if (! $user || ! Hash::check($password, $user->password)) {
RateLimiter::hit($throttleKey, 60);
// Generic message on purpose — never reveal whether the login
// identifier itself exists (no user enumeration).
throw ValidationException::withMessages([
'login' => 'These credentials do not match our records.',
]);
}
RateLimiter::clear($throttleKey);
if ($user->two_factor_enabled) {
$challengeToken = Str::random(40);
Cache::put("2fa_challenge:{$challengeToken}", [
'user_id' => $user->id,
'ip' => $request->ip(),
'attempts' => 0,
], now()->addMinutes(5));
return ['status' => 'requires_2fa', 'challenge_token' => $challengeToken];
}
Auth::login($user);
$request->session()->regenerate();
return ['status' => 'authenticated', 'user' => $user];
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Actions\Auth;
use App\Models\User;
use App\Services\TwoFactorService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\ValidationException;
class VerifyTwoFactorAction
{
public function __construct(private readonly TwoFactorService $twoFactor) {}
public function execute(Request $request, string $challengeToken, string $code): User
{
$cacheKey = "2fa_challenge:{$challengeToken}";
$challenge = Cache::get($cacheKey);
if (! $challenge || $challenge['ip'] !== $request->ip()) {
throw ValidationException::withMessages([
'challenge_token' => 'This challenge has expired. Please log in again.',
]);
}
if ($challenge['attempts'] >= 5) {
Cache::forget($cacheKey);
throw ValidationException::withMessages([
'code' => 'Too many failed attempts. Please log in again.',
]);
}
$user = User::findOrFail($challenge['user_id']);
$valid = $this->twoFactor->verifyCode($user->two_factor_secret, $code)
|| $this->twoFactor->redeemRecoveryCode($user, $code);
if (! $valid) {
$challenge['attempts']++;
Cache::put($cacheKey, $challenge, now()->addMinutes(5));
throw ValidationException::withMessages([
'code' => 'That code is invalid.',
]);
}
Cache::forget($cacheKey);
Auth::login($user);
$request->session()->regenerate();
return $user;
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Actions\Users;
use App\Models\User;
class AssignRoleAction
{
public function execute(User $user, string $role): User
{
$user->syncRoles([$role]);
return $user;
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Actions\Users;
use App\Models\User;
class CreateUserAction
{
/**
* @param array{name: string, username: string, email: string, password: string, role: string} $data
*/
public function execute(array $data, User $actor): User
{
$user = User::create([
'name' => $data['name'],
'username' => $data['username'],
'email' => $data['email'],
'password' => $data['password'],
'created_by' => $actor->id,
]);
// The DB default (false) applies, but isn't hydrated back onto this
// in-memory instance after insert — set it explicitly for the
// response/event payload built from this same object.
$user->two_factor_enabled = false;
$user->assignRole($data['role']);
return $user;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Actions\Users;
use App\Models\User;
class DeleteUserAction
{
public function execute(User $user): void
{
$user->delete();
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Actions\Users;
use App\Models\User;
class UpdateUserAction
{
/**
* @param array{name?: string, username?: string, email?: string, password?: string} $data
*/
public function execute(User $user, array $data): User
{
$user->fill(array_filter($data, fn ($value) => $value !== null && $value !== ''));
$user->save();
return $user;
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RoleAssigned
{
use Dispatchable, SerializesModels;
public function __construct(public User $user, public User $actor, public string $role) {}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserCreated
{
use Dispatchable, SerializesModels;
public function __construct(public User $user, public User $actor) {}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserDeleted
{
use Dispatchable, SerializesModels;
public function __construct(public User $user, public User $actor) {}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserUpdated
{
use Dispatchable, SerializesModels;
public function __construct(public User $user, public User $actor) {}
}
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Auth\LoginAction;
use App\Actions\Auth\VerifyTwoFactorAction;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\TwoFactorChallengeRequest;
use App\Http\Requests\Auth\TwoFactorConfirmRequest;
use App\Http\Requests\Auth\TwoFactorDisableRequest;
use App\Http\Resources\UserResource;
use App\Services\Audit\AuditLogger;
use App\Services\TwoFactorService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function login(LoginRequest $request, LoginAction $action, AuditLogger $audit)
{
$result = $action->execute($request, $request->string('login')->toString(), $request->string('password')->toString());
if ($result['status'] === 'requires_2fa') {
return response()->json(['requires_2fa' => true, 'challenge_token' => $result['challenge_token']]);
}
$audit->log($result['user'], 'auth.login');
return UserResource::make($result['user']);
}
public function verifyTwoFactor(TwoFactorChallengeRequest $request, VerifyTwoFactorAction $action, AuditLogger $audit)
{
$user = $action->execute($request, $request->string('challenge_token')->toString(), $request->string('code')->toString());
$audit->log($user, 'auth.2fa_verified');
return UserResource::make($user);
}
public function logout(Request $request, AuditLogger $audit)
{
$audit->log($request->user(), 'auth.logout');
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
}
public function me(Request $request)
{
return UserResource::make($request->user());
}
public function enableTwoFactor(Request $request, TwoFactorService $twoFactor)
{
$user = $request->user();
$secret = $twoFactor->generateSecret();
// Stored but not yet "enabled" until confirmed with a valid code.
$user->forceFill(['two_factor_secret' => $secret])->save();
return response()->json([
'secret' => $secret,
'otpauth_url' => $twoFactor->otpAuthUrl($user, $secret),
]);
}
public function confirmTwoFactor(TwoFactorConfirmRequest $request, TwoFactorService $twoFactor, AuditLogger $audit)
{
$user = $request->user();
if (! $user->two_factor_secret || ! $twoFactor->verifyCode($user->two_factor_secret, $request->string('code')->toString())) {
throw ValidationException::withMessages(['code' => 'That code is invalid.']);
}
$user->forceFill([
'two_factor_enabled' => true,
'two_factor_confirmed_at' => now(),
])->save();
$recoveryCodes = $twoFactor->generateRecoveryCodes($user);
$audit->log($user, 'auth.2fa_enabled');
return response()->json(['recovery_codes' => $recoveryCodes]);
}
public function disableTwoFactor(TwoFactorDisableRequest $request, AuditLogger $audit)
{
$user = $request->user();
if (! Hash::check($request->string('password')->toString(), $user->password)) {
throw ValidationException::withMessages(['password' => 'That password is incorrect.']);
}
$user->twoFactorRecoveryCodes()->delete();
$user->forceFill([
'two_factor_enabled' => false,
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
])->save();
$audit->log($user, 'auth.2fa_disabled');
return response()->noContent();
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\AuditLogResource;
use App\Models\AuditLog;
use App\Models\User;
use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
class DashboardController extends Controller
{
public function stats(Request $request)
{
abort_unless($request->user()->can('dashboard.view'), 403);
$usersByRole = Role::where('name', '!=', 'super-admin')
->pluck('name')
->mapWithKeys(fn (string $role) => [$role => User::role($role)->count()]);
return response()->json([
'total_users' => User::count(),
'users_by_role' => (object) $usersByRole->all(),
]);
}
public function activity(Request $request)
{
abort_unless($request->user()->can('audit.view'), 403);
$logs = AuditLog::with('user')->latest()->limit(10)->get();
return AuditLogResource::collection($logs);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class MqttController extends Controller
{
public function token(Request $request)
{
$user = $request->user();
$roleKey = match (true) {
$user->hasAnyRole(['super-admin', 'admin']) => 'admin',
$user->hasRole('manager') => 'manager',
default => 'user',
};
$credentials = config("mqtt.role_credentials.{$roleKey}");
return response()->json([
'ws_url' => config('mqtt.ws_url'),
'username' => $credentials['username'],
'password' => $credentials['password'],
]);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\RoleResource;
use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
class RoleController extends Controller
{
public function index(Request $request)
{
abort_unless($request->user()->can('roles.view'), 403);
// Super Admin is intentionally excluded — it's granted only via
// seeding/tinker, never assignable through the Users module.
return RoleResource::collection(Role::where('name', '!=', 'super-admin')->orderBy('name')->get());
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Users\AssignRoleAction;
use App\Actions\Users\CreateUserAction;
use App\Actions\Users\DeleteUserAction;
use App\Actions\Users\UpdateUserAction;
use App\Events\RoleAssigned;
use App\Events\UserCreated;
use App\Events\UserDeleted;
use App\Events\UserUpdated;
use App\Http\Controllers\Controller;
use App\Http\Requests\Users\AssignRoleRequest;
use App\Http\Requests\Users\StoreUserRequest;
use App\Http\Requests\Users\UpdateUserRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\Audit\AuditLogger;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function index(Request $request)
{
$this->authorize('viewAny', User::class);
$query = User::query()->with('roles');
if ($search = $request->string('search')->toString()) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
$sort = in_array($request->query('sort'), ['name', 'username', 'email', 'created_at'], true)
? $request->query('sort')
: 'created_at';
$direction = $request->query('direction') === 'asc' ? 'asc' : 'desc';
$users = $query->orderBy($sort, $direction)->paginate($request->integer('per_page', 15));
return UserResource::collection($users);
}
public function store(StoreUserRequest $request, CreateUserAction $action, AuditLogger $audit)
{
$user = $action->execute($request->validated(), $request->user());
event(new UserCreated($user, $request->user()));
$audit->log($request->user(), 'user.created', $user);
return UserResource::make($user)->response()->setStatusCode(201);
}
public function show(Request $request, User $user)
{
$this->authorize('view', $user);
return UserResource::make($user->load('roles'));
}
public function update(UpdateUserRequest $request, User $user, UpdateUserAction $action, AuditLogger $audit)
{
$user = $action->execute($user, $request->validated());
event(new UserUpdated($user, $request->user()));
$audit->log($request->user(), 'user.updated', $user);
return UserResource::make($user);
}
public function destroy(Request $request, User $user, DeleteUserAction $action, AuditLogger $audit)
{
$this->authorize('delete', $user);
$action->execute($user);
event(new UserDeleted($user, $request->user()));
$audit->log($request->user(), 'user.deleted', $user);
return response()->noContent();
}
public function assignRole(AssignRoleRequest $request, User $user, AssignRoleAction $action, AuditLogger $audit)
{
$role = $request->string('role')->toString();
$user = $action->execute($user, $role);
event(new RoleAssigned($user, $request->user(), $role));
$audit->log($request->user(), 'user.role_assigned', $user, ['role' => $role]);
return UserResource::make($user);
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set(
'Content-Security-Policy',
"default-src 'none'; frame-ancestors 'none'"
);
if ($request->secure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'login' => ['required', 'string'],
'password' => ['required', 'string'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
class TwoFactorChallengeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'challenge_token' => ['required', 'string'],
'code' => ['required', 'string'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
class TwoFactorConfirmRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'code' => ['required', 'string'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
class TwoFactorDisableRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'password' => ['required', 'string'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Users;
use Illuminate\Foundation\Http\FormRequest;
class AssignRoleRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('roles.assign');
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'role' => ['required', 'string', 'in:admin,manager,user'],
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Users;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('users.create');
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', 'unique:users,username'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', Password::min(8)],
'role' => ['required', 'string', 'in:admin,manager,user'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Users;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class UpdateUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('user'));
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$userId = $this->route('user')->id;
return [
'name' => ['sometimes', 'string', 'max:255'],
'username' => ['sometimes', 'string', 'max:255', Rule::unique('users', 'username')->ignore($userId)],
'email' => ['sometimes', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)],
'password' => ['sometimes', 'nullable', 'string', Password::min(8)],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AuditLogResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'action' => $this->action,
'actor_name' => $this->user?->name ?? 'System',
'created_at' => $this->created_at,
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class RoleResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'name' => $this->name,
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->public_id,
'name' => $this->name,
'username' => $this->username,
'email' => $this->email,
'two_factor_enabled' => $this->two_factor_enabled,
'roles' => $this->getRoleNames(),
'permissions' => $this->getAllPermissions()->pluck('name'),
'created_at' => $this->created_at,
'can_edit' => $request->user()?->can('users.update') ?? false,
'can_delete' => $request->user()?->can('users.delete') && $request->user()->id !== $this->id,
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Listeners;
use App\Events\RoleAssigned;
use App\Events\UserCreated;
use App\Events\UserDeleted;
use App\Events\UserUpdated;
use App\Services\Mqtt\MqttPublisher;
use Illuminate\Contracts\Queue\ShouldQueue;
class PublishUserEventToMqtt implements ShouldQueue
{
public int $tries = 3;
public array $backoff = [5, 15, 30];
public function handle(UserCreated|UserUpdated|UserDeleted|RoleAssigned $event): void
{
// A plain payload, not UserResource — this broadcasts to every
// subscriber regardless of viewer, so per-viewer fields like
// can_edit/can_delete don't belong here.
$payload = [
'type' => class_basename($event),
'user' => [
'id' => $event->user->public_id,
'name' => $event->user->name,
'username' => $event->user->username,
'email' => $event->user->email,
'roles' => $event->user->getRoleNames(),
],
'actor_id' => $event->actor->public_id,
'ts' => now()->toIso8601String(),
];
MqttPublisher::publish('module/users/events', $payload);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class AuditLog extends Model
{
public const UPDATED_AT = null;
protected $fillable = [
'user_id',
'action',
'auditable_type',
'auditable_id',
'ip_address',
'user_agent',
'metadata',
];
protected function casts(): array
{
return [
'metadata' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function auditable(): MorphTo
{
return $this->morphTo();
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TwoFactorRecoveryCode extends Model
{
protected $fillable = [
'user_id',
'code_hash',
'used_at',
];
protected function casts(): array
{
return [
'used_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'username',
'email',
'password',
'created_by',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
];
public function twoFactorRecoveryCodes(): HasMany
{
return $this->hasMany(TwoFactorRecoveryCode::class);
}
/**
* Route-model binding (and URLs) use the UUID, never the numeric PK.
*/
public function getRouteKeyName(): string
{
return 'public_id';
}
protected static function booted(): void
{
static::creating(function (User $user) {
$user->public_id ??= (string) Str::uuid();
});
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_enabled' => 'boolean',
'two_factor_secret' => 'encrypted',
'two_factor_confirmed_at' => 'datetime',
];
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Policies;
use App\Models\User;
class UserPolicy
{
public function viewAny(User $user): bool
{
return $user->can('users.view');
}
public function view(User $user, User $model): bool
{
return $user->can('users.view');
}
public function create(User $user): bool
{
return $user->can('users.create');
}
public function update(User $user, User $model): bool
{
return $user->can('users.update');
}
public function delete(User $user, User $model): bool
{
// Prevent self-delete even for roles that hold users.delete.
return $user->can('users.delete') && $user->id !== $model->id;
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Providers;
use App\Events\RoleAssigned;
use App\Events\UserCreated;
use App\Events\UserDeleted;
use App\Events\UserUpdated;
use App\Listeners\PublishUserEventToMqtt;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Defense-in-depth: super-admin bypasses every permission/policy check,
// even if a permission is later removed or misconfigured — except
// self-delete, which must never be allowed for any role, so that
// check is enforced here too rather than left to fall through to
// a policy this bypass would otherwise short-circuit.
Gate::before(function (User $user, string $ability, array $arguments = []) {
if ($ability === 'delete' && ($arguments[0] ?? null) instanceof User && $arguments[0]->id === $user->id) {
return false;
}
return $user->hasRole('super-admin') ? true : null;
});
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Registered explicitly — auto-discovery doesn't reliably match a
// union-typed handle() parameter across multiple event classes.
Event::listen([UserCreated::class, UserUpdated::class, UserDeleted::class, RoleAssigned::class], PublishUserEventToMqtt::class);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Services\Audit;
use App\Models\AuditLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Request;
class AuditLogger
{
public function log(User $actor, string $action, ?Model $auditable = null, array $metadata = []): AuditLog
{
return AuditLog::create([
'user_id' => $actor->id,
'action' => $action,
'auditable_type' => $auditable?->getMorphClass(),
'auditable_id' => $auditable?->getKey(),
'ip_address' => Request::ip(),
'user_agent' => Request::userAgent(),
'metadata' => $metadata,
]);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
/**
* Scaffolded ahead of any actual upload feature (e.g. avatars) so hardening
* isn't an afterthought bolted on later. Not wired to a route yet.
*/
class FileUploadValidator
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
];
private const MAX_BYTES = 5 * 1024 * 1024; // 5MB
public function validate(UploadedFile $file): bool
{
if ($file->getSize() > self::MAX_BYTES) {
return false;
}
// Sniff actual content type rather than trusting the client-supplied
// extension or Content-Type header.
return in_array($file->getMimeType(), self::ALLOWED_MIME_TYPES, true);
}
/**
* A randomized filename that never trusts the client-supplied name.
*/
public function randomizedFilename(UploadedFile $file): string
{
return Str::uuid().'.'.$file->extension();
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Services\Mqtt;
use PhpMqtt\Client\Facades\MQTT;
use PhpMqtt\Client\MqttClient;
class MqttPublisher
{
public static function publish(string $topic, array $payload, int $qos = MqttClient::QOS_AT_LEAST_ONCE): void
{
MQTT::connection()->publish($topic, json_encode($payload), $qos);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Services;
use App\Models\TwoFactorRecoveryCode;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use PragmaRX\Google2FA\Google2FA;
class TwoFactorService
{
public function __construct(private readonly Google2FA $google2fa) {}
public function generateSecret(): string
{
return $this->google2fa->generateSecretKey();
}
public function otpAuthUrl(User $user, string $secret): string
{
return $this->google2fa->getQRCodeUrl(
config('app.name'),
$user->email,
$secret,
);
}
public function verifyCode(string $secret, string $code): bool
{
return $this->google2fa->verifyKey($secret, $code, window: 1);
}
/**
* @return list<string> the plaintext recovery codes (shown once, never stored plaintext)
*/
public function generateRecoveryCodes(User $user): array
{
$user->twoFactorRecoveryCodes()->delete();
$plaintextCodes = [];
foreach (range(1, 10) as $_) {
$code = Str::random(10);
$plaintextCodes[] = $code;
TwoFactorRecoveryCode::create([
'user_id' => $user->id,
'code_hash' => Hash::make($code),
]);
}
return $plaintextCodes;
}
public function redeemRecoveryCode(User $user, string $code): bool
{
$recoveryCode = $user->twoFactorRecoveryCodes()
->whereNull('used_at')
->get()
->first(fn (TwoFactorRecoveryCode $recoveryCode) => Hash::check($code, $recoveryCode->code_hash));
if (! $recoveryCode) {
return false;
}
$recoveryCode->update(['used_at' => now()]);
return true;
}
}