Autenticação responde "quem é você?" — verifica identidade. Autorização responde "o que você pode fazer?" — verifica permissões. Laravel oferece infraestrutura completa para ambas, com flexibilidade para ir do simples ao complexo conforme o sistema cresce.
Laravel Breeze — scaffolding de autenticação
Para a maioria dos projetos, o ponto de partida é o Laravel Breeze:
composer require laravel/breeze --dev
php artisan breeze:install blade # ou: react, vue, api
php artisan migrate
npm install && npm run dev
O Breeze instala: rotas de login/registro/logout/reset de senha, controllers, views Blade completas, e middleware de autenticação — tudo pronto para customizar.
Autenticação manual
Entender como funciona por baixo é fundamental:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
public function store(Request $request): RedirectResponse
{
$credenciais = $request->validate([
'email' => ['required', 'email'],
'senha' => ['required'],
]);
// Auth::attempt verifica email + senha e cria a sessão
if (!Auth::attempt(
['email' => $credenciais['email'], 'password' => $credenciais['senha']],
$request->boolean('lembrar'), // "Lembrar de mim"
)) {
throw ValidationException::withMessages([
'email' => 'Email ou senha incorretos.',
]);
}
// Regenera session ID — previne session fixation
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}
public function destroy(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}
Middleware de autenticação
<?php
// routes/web.php
// Proteger rotas com middleware auth
Route::middleware('auth')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::resource('pedidos', PedidoController::class);
});
// Redirecionar usuários já autenticados
Route::middleware('guest')->group(function () {
Route::get('/login', [LoginController::class, 'create'])->name('login');
Route::post('/login', [LoginController::class, 'store']);
});
Autorização com Gates e Policies
Gates — regras simples de autorização definidas em closures:
<?php
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('editar-produto', function (User $usuario, Produto $produto) {
return $usuario->isAdmin() || $produto->criado_por === $usuario->id;
});
Gate::define('acessar-admin', function (User $usuario) {
return $usuario->role === 'admin';
});
}
// Uso no controller:
public function update(Request $request, Produto $produto): RedirectResponse
{
Gate::authorize('editar-produto', $produto); // lança 403 se não autorizado
$produto->update($request->validated());
return redirect()->route('produtos.show', $produto);
}
// Verificação sem lançar exceção:
if (Gate::allows('acessar-admin')) {
// mostra menu admin
}
if (Gate::denies('editar-produto', $produto)) {
abort(403);
}
Policies — autorização encapsulada por Model, para projetos maiores:
php artisan make:policy ProdutoPolicy --model=Produto
<?php
namespace App\Policies;
use App\Models\Produto;
use App\Models\User;
class ProdutoPolicy
{
// Antes de qualquer método — admin pode tudo
public function before(User $user): ?bool
{
if ($user->isAdmin()) return true;
return null; // null = continua verificando o método específico
}
public function viewAny(User $user): bool
{
return true; // qualquer usuário autenticado pode listar
}
public function view(User $user, Produto $produto): bool
{
return $produto->ativo || $user->isAdmin();
}
public function create(User $user): bool
{
return $user->hasPermission('criar-produto');
}
public function update(User $user, Produto $produto): bool
{
return $user->id === $produto->criado_por;
}
public function delete(User $user, Produto $produto): bool
{
return $user->id === $produto->criado_por && $produto->pedidos()->count() === 0;
}
}
// Registrar a policy (Laravel detecta automaticamente por convenção)
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Gate;
Gate::policy(Produto::class, ProdutoPolicy::class);
// Uso no controller.
// ATENÇÃO na versão: até o Laravel 10 o `Controller` base do skeleton
// vinha com `use AuthorizesRequests`, e por isso `$this->authorize()`
// funcionava em qualquer controller. **Do Laravel 11 em diante o
// `app/Http/Controllers/Controller.php` é uma classe abstrata vazia** —
// chamar `$this->authorize()` dá `Call to undefined method`. Ou se
// declara o trait na classe, como abaixo, ou se usa a facade
// `Gate::authorize()`, que independe de trait.
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class ProdutoController extends Controller
{
use AuthorizesRequests;
public function update(ProdutoRequest $request, Produto $produto): RedirectResponse
{
$this->authorize('update', $produto); // usa ProdutoPolicy::update()
$produto->update($request->validated());
return redirect()->route('produtos.show', $produto);
}
}
// No Blade:
@can('update', $produto)
<a href="{{ route('produtos.edit', $produto) }}">Editar</a>
@endcan
@cannot('delete', $produto)
<span class="desabilitado">Não pode deletar</span>
@endcannot
Roles e Permissions — sistema completo
Para sistemas maiores, o pacote spatie/laravel-permission é padrão do mercado:
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
<?php
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
// Setup de roles e permissions
$admin = Role::create(['name' => 'admin']);
$editor = Role::create(['name' => 'editor']);
$cliente = Role::create(['name' => 'cliente']);
Permission::create(['name' => 'criar produto']);
Permission::create(['name' => 'editar produto']);
Permission::create(['name' => 'deletar produto']);
Permission::create(['name' => 'ver pedidos']);
$admin->givePermissionTo(Permission::all());
$editor->givePermissionTo(['criar produto', 'editar produto']);
// Atribuir role ao usuário
$usuario->assignRole('editor');
$usuario->givePermissionTo('ver pedidos');
// Verificações
$usuario->hasRole('admin');
$usuario->can('deletar produto');
$usuario->hasAnyRole(['admin', 'editor']);
// No Blade:
@role('admin')
<a href="/admin">Painel Admin</a>
@endrole
@hasanyrole('admin|editor')
<a href="/produtos/criar">Criar Produto</a>
@endhasanyrole
A divisão entre autenticação e autorização parece acadêmica até o dia em que ela se desfaz no código. Um middleware auth na rota responde só a primeira pergunta, e é fácil concluir que o resto está resolvido — afinal, quem chegou ali está logado. A segunda pergunta continua aberta, e ela é a que protege o dado: este usuário autenticado pode ver este pedido, editar este produto, apagar este comentário. Rota protegida com {id} no caminho e sem policy é o buraco mais comum de aplicação Laravel em produção, e o mais silencioso, porque tudo funciona perfeitamente enquanto cada um só troca o próprio id.
Sobre a escolha de ferramenta, a régua é menos sofisticada do que parece. Gate para a regra que existe uma vez e não tem model por trás — acessar o painel, ver o relatório. Policy quando a regra é sobre um model e acompanha o ciclo dele, que é a maioria dos casos. Pacote de roles quando as permissões passam a ser cadastradas pelo cliente em vez de escritas por você. Trocar de nível é barato no começo e caro depois, então vale começar pela policy: ela cabe num arquivo, o Laravel a encontra por convenção e, no dia em que as permissões virarem dados, é dentro dela que a consulta entra — sem tocar em nenhum controller.
Fontes e leituras recomendadas
- Laravel Authentication: https://laravel.com/docs/authentication
- Laravel Authorization: https://laravel.com/docs/authorization
- Spatie Laravel Permission: https://spatie.be/docs/laravel-permission
- Laravel Breeze: https://laravel.com/docs/starter-kits#laravel-breeze
- Laravel — hashing, com
bcrypteargon2ide o custo configuravel: https://laravel.com/docs/hashing - Laravel Fortify — backend de autenticacao sem view, para quando a interface e sua: https://laravel.com/docs/fortify
Exercícios
Exercício 1
Implemente autenticação completa sem Breeze: login, logout, registro, proteção de rotas. Use Auth::attempt() e session()->regenerate() manualmente.
Ver resposta
✓ Resposta: Duas linhas fazem o trabalho de segurança de verdade e são justamente as que se esquece: session()->regenerate() no login (sem ela, um id de sessão plantado antes continua valendo — session fixation) e o par invalidate()/regenerateToken() no logout. Some a isso o RateLimiter chaveado por e-mail + IP: só por IP pune o escritório inteiro atrás do mesmo NAT, só por e-mail deixa varrer contas do mesmo lugar. E a mensagem de erro é genérica de propósito — "e-mail não cadastrado" entrega a lista de quem tem conta.
<?php
// ---------------------------------------- app/Http/Controllers/Auth/LoginController.php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class LoginController extends Controller
{
public function mostrarFormulario(): View
{
return view('auth.login');
}
public function entrar(Request $request): RedirectResponse
{
$credenciais = $request->validate([
'email' => ['required', 'email'],
'senha' => ['required', 'string'],
]);
// Limite ANTES de consultar o banco: sem ele, o formulário de login
// é um oráculo de força bruta com o banco pagando a conta.
$chave = $this->chaveDeLimite($request);
if (RateLimiter::tooManyAttempts($chave, maxAttempts: 5)) {
$segundos = RateLimiter::availableIn($chave);
throw ValidationException::withMessages([
'email' => "Muitas tentativas. Tente de novo em {$segundos} segundos.",
]);
}
$lembrar = $request->boolean('lembrar');
// Auth::attempt() já faz o password_verify com hash_equals por
// baixo — não reimplemente a comparação.
if (!Auth::attempt(
['email' => $credenciais['email'], 'password' => $credenciais['senha']],
$lembrar,
)) {
RateLimiter::hit($chave, decaySeconds: 60);
// Mensagem GENÉRICA de propósito: dizer "e-mail não cadastrado"
// entrega ao atacante a lista de quem tem conta no sistema.
throw ValidationException::withMessages([
'email' => 'Credenciais inválidas.',
]);
}
RateLimiter::clear($chave);
// A linha que impede session fixation: o id de sessão que o
// atacante plantou antes do login deixa de valer. Sem ela, quem
// conhecia o id antigo entra junto.
$request->session()->regenerate();
Auth::user()->forceFill(['ultimo_login_em' => now()])->save();
// intended() volta para a página que o usuário tentou abrir antes
// de ser mandado para o login.
return redirect()->intended(route('painel'));
}
public function sair(Request $request): RedirectResponse
{
Auth::logout();
// Os três passos, nesta ordem. Sem o invalidate(), os dados da
// sessão continuam vivos; sem o regenerateToken(), o próximo POST
// falha com 419 porque o token velho não vale mais.
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->with('sucesso', 'Você saiu da conta.');
}
private function chaveDeLimite(Request $request): string
{
// Chave por e-mail + IP: limitar só por IP pune o escritório
// inteiro atrás do mesmo NAT; só por e-mail permite varrer contas
// uma a uma a partir do mesmo lugar.
return Str::transliterate(
Str::lower((string) $request->input('email')) . '|' . $request->ip()
);
}
}
// ---------------------------------------- app/Http/Controllers/Auth/RegistroController.php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Usuario;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class RegistroController extends Controller
{
public function mostrarFormulario(): View
{
return view('auth.registro');
}
public function registrar(Request $request): RedirectResponse
{
$dados = $request->validate([
'nome' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:usuarios,email'],
'senha' => [
'required',
'confirmed', // exige o campo senha_confirmation
Password::min(8)
->letters()
->numbers()
// Consulta a base do haveibeenpwned por k-anonimato:
// só os 5 primeiros caracteres do hash saem daqui, a
// senha nunca trafega.
->uncompromised(),
],
]);
$usuario = Usuario::create([
'nome' => $dados['nome'],
'email' => $dados['email'],
// Hash::make usa bcrypt e já embute o salt aleatório. Nunca
// md5, nunca sha1, nunca "senha + salt fixo".
'senha' => Hash::make($dados['senha']),
]);
// O evento dispara o e-mail de verificação (e o que mais alguém
// pendurar nele) sem acoplar isso ao controller.
event(new Registered($usuario));
Auth::login($usuario);
$request->session()->regenerate();
return redirect()->route('painel')->with('sucesso', 'Conta criada!');
}
}
// ---------------------------------------- app/Models/Usuario.php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class Usuario extends Authenticatable implements MustVerifyEmail
{
use HasFactory;
use Notifiable;
protected $table = 'usuarios';
protected $fillable = ['nome', 'email', 'senha'];
// Nunca aparecem em toArray()/toJson() — é o que evita o hash da senha
// vazar numa resposta de API por descuido.
protected $hidden = ['senha', 'remember_token'];
protected function casts(): array
{
return [
'email_verificado_em' => 'datetime',
// O cast 'hashed' aplica Hash::make ao ATRIBUIR: mesmo que
// alguém salve senha crua, o banco recebe o hash.
'senha' => 'hashed',
];
}
/**
* O Laravel procura a coluna `password`. Como a nossa se chama
* `senha`, é preciso dizer — senão Auth::attempt sempre falha, sem
* mensagem de erro nenhuma.
*/
public function getAuthPassword(): string
{
return $this->senha;
}
public function isAdmin(): bool
{
return $this->perfil === 'admin';
}
}
// ---------------------------------------- routes/web.php
declare(strict_types=1);
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\RegistroController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function (): void {
// 'guest' redireciona quem já está logado — sem ele, o usuário
// autenticado consegue abrir a tela de login e se confundir.
Route::get('/login', [LoginController::class, 'mostrarFormulario'])->name('login');
Route::post('/login', [LoginController::class, 'entrar']);
Route::get('/registro', [RegistroController::class, 'mostrarFormulario'])->name('registro');
Route::post('/registro', [RegistroController::class, 'registrar']);
});
Route::middleware('auth')->group(function (): void {
// Logout é POST, nunca GET: um <img src="/logout"> numa página
// qualquer deslogaria o usuário sem que ele clicasse em nada.
Route::post('/logout', [LoginController::class, 'sair'])->name('logout');
Route::view('/painel', 'painel')->name('painel');
// Rotas que exigem e-mail confirmado E senha reconfirmada há menos de
// 3 horas — o padrão para operação sensível.
Route::middleware(['verified', 'password.confirm'])->group(function (): void {
Route::view('/conta/excluir', 'conta.excluir')->name('conta.excluir');
});
});
// ---------------------------------------- tests/Feature/AutenticacaoTest.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Usuario;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class AutenticacaoTest extends TestCase
{
use RefreshDatabase;
public function test_login_com_credenciais_validas(): void
{
$usuario = Usuario::factory()->create(['senha' => Hash::make('segredo123')]);
$this->post('/login', ['email' => $usuario->email, 'senha' => 'segredo123'])
->assertRedirect(route('painel'));
$this->assertAuthenticatedAs($usuario);
}
public function test_senha_errada_nao_autentica(): void
{
$usuario = Usuario::factory()->create(['senha' => Hash::make('segredo123')]);
$this->post('/login', ['email' => $usuario->email, 'senha' => 'errada'])
->assertSessionHasErrors('email');
$this->assertGuest();
}
public function test_a_sessao_e_regenerada_no_login(): void
{
$usuario = Usuario::factory()->create(['senha' => Hash::make('segredo123')]);
$this->startSession();
$idAntigo = session()->getId();
$this->post('/login', ['email' => $usuario->email, 'senha' => 'segredo123']);
// O teste que trava a defesa contra session fixation.
$this->assertNotSame($idAntigo, session()->getId());
}
public function test_excesso_de_tentativas_e_bloqueado(): void
{
$usuario = Usuario::factory()->create();
foreach (range(1, 6) as $tentativa) {
$resposta = $this->post('/login', [
'email' => $usuario->email,
'senha' => 'errada',
]);
}
$resposta->assertSessionHasErrors('email');
$this->assertStringContainsString(
'Muitas tentativas',
session('errors')->first('email'),
);
}
public function test_logout_encerra_a_sessao(): void
{
$usuario = Usuario::factory()->create();
$this->actingAs($usuario)->post('/logout')->assertRedirect(route('login'));
$this->assertGuest();
}
}
Exercício 2
Crie uma PedidoPolicy com métodos view, create, update, cancel. Um usuário só pode ver e cancelar seus próprios pedidos. Admins podem tudo.
Ver resposta
✓ Resposta: Duas sutilezas que valem a leitura. No before(), devolver false bloqueia tudo, inclusive o que os métodos liberariam — o "não decido" é null. E denyAsNotFound() em vez de deny() para recurso alheio: um 403 confirma que aquele pedido existe, o que já é informação demais para quem está sondando ids sequenciais. O erro estrutural mais comum, porém, é outro: policy autoriza acesso a um registro, não filtra listagem — quem esquece o where('usuario_id', ...) no index mostra o pedido de todo mundo.
<?php
// ============ no terminal ============
// php artisan make:policy PedidoPolicy --model=Pedido
//
// O Laravel 11 descobre a policy pela convenção de nome
// (App\Models\Pedido → App\Policies\PedidoPolicy). Registrar no
// AuthServiceProvider só é preciso quando o nome foge do padrão.
// ---------------------------------------- app/Policies/PedidoPolicy.php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Pedido;
use App\Models\StatusPedido;
use App\Models\Usuario;
use Illuminate\Auth\Access\Response;
class PedidoPolicy
{
/**
* before() roda ANTES de todos os métodos. Devolver true libera geral;
* devolver null (não false!) deixa a decisão para o método específico.
*
* Cuidado: `return false` aqui BLOQUEIA tudo, inclusive o que os
* métodos liberariam. É por isso que o else devolve null.
*/
public function before(Usuario $usuario, string $habilidade): ?bool
{
return $usuario->isAdmin() ? true : null;
}
public function viewAny(Usuario $usuario): bool
{
// Todo mundo pode abrir "meus pedidos" — o filtro de quais pedidos
// aparecem é da consulta, não da policy.
return true;
}
/**
* Response em vez de bool: a mensagem chega ao usuário no lugar do
* "This action is unauthorized." genérico.
*/
public function view(Usuario $usuario, Pedido $pedido): Response
{
return $pedido->usuario_id === $usuario->id
? Response::allow()
// denyAsNotFound() devolve 404 em vez de 403: não confirma
// sequer que o pedido existe. Para recurso de outra pessoa,
// é a resposta certa.
: Response::denyAsNotFound();
}
public function create(Usuario $usuario): Response
{
return $usuario->hasVerifiedEmail()
? Response::allow()
: Response::deny('Confirme seu e-mail antes de fazer pedidos.');
}
public function update(Usuario $usuario, Pedido $pedido): Response
{
if ($pedido->usuario_id !== $usuario->id) {
return Response::denyAsNotFound();
}
// Regra de negócio dentro da policy: pedido já confirmado não é
// mais editável pelo cliente.
return $pedido->status === StatusPedido::Rascunho
? Response::allow()
: Response::deny('Pedidos já confirmados não podem ser alterados.');
}
public function cancel(Usuario $usuario, Pedido $pedido): Response
{
if ($pedido->usuario_id !== $usuario->id) {
return Response::denyAsNotFound();
}
if (!$pedido->status->podeSerCancelado()) {
return Response::deny(
"Pedido {$pedido->status->rotulo()} não pode ser cancelado."
);
}
// Prazo: 7 dias após a confirmação.
if ($pedido->confirmado_em?->addDays(7)->isPast()) {
return Response::deny('O prazo de cancelamento (7 dias) já passou.');
}
return Response::allow();
}
public function delete(Usuario $usuario, Pedido $pedido): bool
{
// Só o before() (admin) chega aqui com true — cliente não apaga
// pedido, cancela.
return false;
}
}
// ---------------------------------------- app/Http/Controllers/PedidoController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Pedido;
use App\Models\StatusPedido;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PedidoController extends Controller
{
public function __construct()
{
// Liga cada método do controller ao método correspondente da
// policy, pelo nome. Cobre index/create/store/show/edit/update/
// destroy de uma vez — e o que passar depois também.
$this->authorizeResource(Pedido::class, 'pedido');
}
public function index(Request $request): View
{
return view('pedidos.index', [
// A policy autoriza o ACESSO; a consulta é que filtra o
// conjunto. Confundir os dois é como listar o pedido de todo
// mundo e torcer para a policy salvar depois.
'pedidos' => Pedido::query()
->when(
!$request->user()->isAdmin(),
fn ($consulta) => $consulta->where('usuario_id', $request->user()->id),
)
->with('produtos')
->latest()
->paginate(15),
]);
}
public function show(Pedido $pedido): View
{
// Redundante com o authorizeResource, mas explícito. Em controller
// sem o construtor acima, esta linha é obrigatória.
$this->authorize('view', $pedido);
return view('pedidos.show', compact('pedido'));
}
public function cancelar(Request $request, Pedido $pedido): RedirectResponse
{
// 'cancel' não é um método padrão de resource: precisa da chamada
// explícita. Esquecer aqui é o furo típico das ações extras.
$this->authorize('cancel', $pedido);
$pedido->update([
'status' => StatusPedido::Cancelado,
'cancelado_em' => now(),
]);
return back()->with('sucesso', "Pedido {$pedido->codigo} cancelado.");
}
}
// ---------------------------------------- na view
// @can('cancel', $pedido)
// <form method="post" action="{{ route('pedidos.cancelar', $pedido) }}">
// @csrf
// <button type="submit">Cancelar pedido</button>
// </form>
// @else
// {{-- @cannot dá acesso à mensagem da Response --}}
// <p class="aviso">{{ Gate::inspect('cancel', $pedido)->message() }}</p>
// @endcan
// ---------------------------------------- tests/Feature/PedidoPolicyTest.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Pedido;
use App\Models\StatusPedido;
use App\Models\Usuario;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PedidoPolicyTest extends TestCase
{
use RefreshDatabase;
public function test_dono_ve_o_proprio_pedido(): void
{
$usuario = Usuario::factory()->create();
$pedido = Pedido::factory()->for($usuario)->create();
$this->actingAs($usuario)->get(route('pedidos.show', $pedido))->assertOk();
}
public function test_pedido_de_outro_devolve_404_e_nao_403(): void
{
$pedido = Pedido::factory()->create();
// 404, não 403: um 403 confirmaria que o pedido existe — que já é
// informação a mais para quem está sondando ids.
$this->actingAs(Usuario::factory()->create())
->get(route('pedidos.show', $pedido))
->assertNotFound();
}
public function test_admin_ve_tudo(): void
{
$pedido = Pedido::factory()->create();
$admin = Usuario::factory()->create(['perfil' => 'admin']);
$this->actingAs($admin)->get(route('pedidos.show', $pedido))->assertOk();
}
public function test_pedido_entregue_nao_pode_ser_cancelado(): void
{
$usuario = Usuario::factory()->create();
$pedido = Pedido::factory()->for($usuario)->create([
'status' => StatusPedido::Entregue,
]);
$this->actingAs($usuario)
->post(route('pedidos.cancelar', $pedido))
->assertForbidden();
}
public function test_prazo_de_cancelamento_expira_em_7_dias(): void
{
$usuario = Usuario::factory()->create();
$pedido = Pedido::factory()->for($usuario)->create([
'status' => StatusPedido::Confirmado,
'confirmado_em' => now()->subDays(8),
]);
$this->actingAs($usuario)
->post(route('pedidos.cancelar', $pedido))
->assertForbidden();
}
public function test_a_listagem_nao_vaza_pedido_alheio(): void
{
$usuario = Usuario::factory()->create();
Pedido::factory()->for($usuario)->create(['codigo' => 'MEU-PEDIDO']);
Pedido::factory()->create(['codigo' => 'ALHEIO']);
$this->actingAs($usuario)
->get(route('pedidos.index'))
->assertSee('MEU-PEDIDO')
->assertDontSee('ALHEIO');
}
}
Exercício 3
Instale spatie/laravel-permission. Crie roles: admin, vendedor, cliente. Defina permissions granulares. Crie um seeder que cria os roles e um admin inicial.
Ver resposta
✓ Resposta: O tropeço número um do pacote é o cache de permissões: você cria a permissão, ela está no banco, e a aplicação insiste que não existe — falta forgetCachedPermissions() (no seeder e no setUp() dos testes). Duas escolhas de projeto: proteger rota por permissão, não por papel — mudar quem exporta relatório vira uma linha de seeder em vez de alteração de rota; e syncPermissions() no seeder, que substitui o conjunto e faz o papel perder o que foi removido do código, coisa que givePermissionTo() nunca faz.
<?php
// ============ no terminal ============
// composer require spatie/laravel-permission
// php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
// php artisan migrate
//
// Cria cinco tabelas: roles, permissions, model_has_roles,
// model_has_permissions e role_has_permissions.
// ---------------------------------------- app/Models/Usuario.php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;
class Usuario extends Authenticatable
{
use HasRoles;
protected $table = 'usuarios';
// A trait acrescenta: assignRole(), removeRole(), hasRole(),
// hasAnyRole(), givePermissionTo(), can() e os escopos role()/permission().
}
// ---------------------------------------- database/seeders/PermissaoSeeder.php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Usuario;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class PermissaoSeeder extends Seeder
{
/**
* Nomenclatura `recurso.acao`: ordena bem, agrupa no painel e evita a
* bagunça de misturar "criar_produto" com "produto-editar".
*
* @var array<string, list<string>>
*/
private const PERMISSOES = [
'produtos' => ['ver', 'criar', 'editar', 'excluir'],
'pedidos' => ['ver', 'ver-todos', 'criar', 'cancelar', 'reembolsar'],
'categorias' => ['ver', 'criar', 'editar', 'excluir'],
'usuarios' => ['ver', 'criar', 'editar', 'excluir', 'atribuir-papel'],
'relatorios' => ['ver', 'exportar'],
];
public function run(): void
{
// O pacote guarda permissões em cache. Sem limpar, o seeder cria as
// linhas e a aplicação continua enxergando o conjunto antigo — o
// "criei a permissão e não funciona" mais comum do pacote.
app(PermissionRegistrar::class)->forgetCachedPermissions();
foreach (self::PERMISSOES as $recurso => $acoes) {
foreach ($acoes as $acao) {
// firstOrCreate deixa o seeder reexecutável: rodar duas
// vezes não duplica nem estoura o unique.
Permission::firstOrCreate([
'name' => "{$recurso}.{$acao}",
'guard_name' => 'web',
]);
}
}
$admin = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
$vendedor = Role::firstOrCreate(['name' => 'vendedor', 'guard_name' => 'web']);
$cliente = Role::firstOrCreate(['name' => 'cliente', 'guard_name' => 'web']);
// syncPermissions substitui o conjunto inteiro — reexecutar o
// seeder corrige permissão removida do código. givePermissionTo()
// só acrescenta, e o papel acumularia lixo antigo.
$admin->syncPermissions(Permission::all());
$vendedor->syncPermissions([
'produtos.ver', 'produtos.criar', 'produtos.editar',
'pedidos.ver', 'pedidos.ver-todos', 'pedidos.cancelar',
'categorias.ver',
'relatorios.ver',
]);
$cliente->syncPermissions([
'produtos.ver',
'categorias.ver',
'pedidos.ver', 'pedidos.criar', 'pedidos.cancelar',
]);
$usuario = Usuario::firstOrCreate(
['email' => 'admin@loja.test'],
['nome' => 'Administradora', 'senha' => Hash::make('password')],
);
$usuario->syncRoles([$admin]);
$this->command->info('Papéis e permissões criados. Admin: admin@loja.test');
}
}
// ---------------------------------------- app/Providers/AppServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use App\Models\Usuario;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Gate::before integra o pacote ao `can()` nativo: o admin passa em
// qualquer verificação sem precisar da permissão listada.
//
// Devolve null (não false) quando não é admin — false aqui
// bloquearia todas as outras checagens.
Gate::before(function (Usuario $usuario, string $habilidade): ?bool {
return $usuario->hasRole('admin') ? true : null;
});
}
}
// ---------------------------------------- routes/web.php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
Route::middleware(['auth'])->group(function (): void {
// Por PERMISSÃO — preferível. Mudar quem pode exportar relatório passa
// a ser uma linha no seeder, não uma alteração de rota.
Route::get('/relatorios', [RelatorioController::class, 'index'])
->middleware('permission:relatorios.ver');
// Por PAPEL — use com parcimônia: amarra a rota à estrutura de cargos.
Route::prefix('admin')->middleware('role:admin')->group(function (): void {
Route::resource('usuarios', UsuarioController::class);
});
// Qualquer um dos dois (o pipe é OU)
Route::get('/painel/vendas', [VendaController::class, 'index'])
->middleware('role_or_permission:vendedor|relatorios.ver');
});
// ---------------------------------------- app/Http/Controllers/ProdutoController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Produto;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ProdutoController extends Controller
{
public function store(Request $request): RedirectResponse
{
// can() com o nome da permissão: o Gate::before deixa o admin
// passar mesmo sem a permissão explícita.
abort_unless($request->user()->can('produtos.criar'), 403);
$produto = Produto::create($request->validated());
return redirect()->route('produtos.show', $produto);
}
}
// ---------------------------------------- na view
// @can('produtos.criar')
// <a href="{{ route('produtos.create') }}">Novo produto</a>
// @endcan
//
// @role('admin')
// <a href="{{ route('admin.usuarios.index') }}">Usuários</a>
// @endrole
//
// @hasanyrole('admin|vendedor')
// <a href="{{ route('relatorios.index') }}">Relatórios</a>
// @endhasanyrole
// ---------------------------------------- tests/Feature/PermissaoTest.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Usuario;
use Database\Seeders\PermissaoSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
class PermissaoTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// O cache do pacote atravessa testes e produz falha fantasma:
// o teste A cria a permissão, o teste B roda com o cache do A.
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->seed(PermissaoSeeder::class);
}
public function test_vendedor_cria_produto(): void
{
$vendedor = Usuario::factory()->create();
$vendedor->assignRole('vendedor');
$this->assertTrue($vendedor->can('produtos.criar'));
$this->assertFalse($vendedor->can('usuarios.excluir'));
}
public function test_cliente_nao_ve_relatorios(): void
{
$cliente = Usuario::factory()->create();
$cliente->assignRole('cliente');
$this->actingAs($cliente)->get('/relatorios')->assertForbidden();
}
public function test_admin_passa_pelo_gate_before(): void
{
$admin = Usuario::factory()->create();
$admin->assignRole('admin');
// Permissão que nem existe: o Gate::before libera antes de checar.
$this->assertTrue($admin->can('qualquer.coisa.inventada'));
}
public function test_seeder_e_reexecutavel(): void
{
$this->seed(PermissaoSeeder::class);
$this->seed(PermissaoSeeder::class);
// firstOrCreate: rodar três vezes não duplica nada.
$this->assertDatabaseCount('roles', 3);
}
}
Exercício 4
Desafio: implemente autenticação de dois fatores (2FA) simples: após login com senha, se o usuário tiver 2FA ativado, redirecione para uma tela de código TOTP. Use o pacote pragmarx/google2fa-laravel.
Ver resposta
✓ Resposta: A peça central do fluxo é Auth::validate() em vez de Auth::attempt(): ele confere a senha sem autenticar — com o attempt, o usuário já estaria dentro e o segundo fator seria enfeite. As outras três: o segredo vai encrypted no banco (em texto puro, quem lê a tabela gera os códigos); a ativação só se confirma depois que o usuário prova que o app funciona — senão ele se tranca para fora da própria conta; e window: 1 tolera 90 segundos de relógio dessincronizado, que é a causa real de quase todo "código inválido".
<?php
// ============ no terminal ============
// composer require pragmarx/google2fa-laravel bacon/bacon-qr-code
// php artisan make:migration adicionar_2fa_em_usuarios
// ------------------- database/migrations/..._adicionar_2fa_em_usuarios.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('usuarios', function (Blueprint $tabela): void {
// text, não string: o segredo vai CRIPTOGRAFADO, e o
// ciphertext do Laravel é bem maior que os 32 caracteres do
// segredo cru.
$tabela->text('dois_fatores_segredo')->nullable();
$tabela->text('dois_fatores_codigos_recuperacao')->nullable();
$tabela->timestamp('dois_fatores_confirmado_em')->nullable();
});
}
public function down(): void
{
Schema::table('usuarios', function (Blueprint $tabela): void {
$tabela->dropColumn([
'dois_fatores_segredo',
'dois_fatores_codigos_recuperacao',
'dois_fatores_confirmado_em',
]);
});
}
};
// ---------------------------------------- app/Models/Usuario.php (trecho)
declare(strict_types=1);
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
class Usuario extends Authenticatable
{
protected $hidden = [
'senha', 'remember_token',
// Segredo de 2FA em resposta de API é o mesmo que publicar a
// segunda senha.
'dois_fatores_segredo', 'dois_fatores_codigos_recuperacao',
];
protected function casts(): array
{
return [
// 'encrypted' cifra ao salvar e decifra ao ler, usando a
// APP_KEY. Segredo TOTP em texto puro no banco anula o 2FA:
// quem lê a tabela gera os códigos.
'dois_fatores_segredo' => 'encrypted',
'dois_fatores_codigos_recuperacao' => 'encrypted:array',
'dois_fatores_confirmado_em' => 'datetime',
];
}
public function temDoisFatores(): bool
{
return $this->dois_fatores_confirmado_em !== null;
}
}
// ---------------------------------------- app/Services/DoisFatores.php
declare(strict_types=1);
namespace App\Services;
use App\Models\Usuario;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use PragmaRX\Google2FA\Google2FA;
class DoisFatores
{
public function __construct(private readonly Google2FA $google2fa) {}
public function gerarSegredo(): string
{
return $this->google2fa->generateSecretKey();
}
/** SVG do QR Code, embutível direto na página com {!! !!}. */
public function qrCodeSvg(Usuario $usuario, string $segredo): string
{
$url = $this->google2fa->getQRCodeUrl(
config('app.name'),
$usuario->email,
$segredo,
);
$escritor = new Writer(new ImageRenderer(
new RendererStyle(200),
new SvgImageBackEnd(),
));
return $escritor->writeString($url);
}
public function verificar(Usuario $usuario, string $codigo): bool
{
if (blank($usuario->dois_fatores_segredo)) {
return false;
}
// window: 1 aceita o código anterior e o próximo — 90 segundos de
// tolerância para relógio dessincronizado. Sem isso, celular
// atrasado nunca entra; com window grande demais, a janela de
// reuso de um código roubado cresce junto.
return (bool) $this->google2fa->verifyKey(
$usuario->dois_fatores_segredo,
$codigo,
window: 1,
);
}
/** @return list<string> */
public function gerarCodigosDeRecuperacao(int $quantidade = 8): array
{
// Perder o celular sem código de recuperação significa perder a
// conta. Estes são a única saída — e por isso são mostrados uma
// única vez, no momento da ativação.
return collect(range(1, $quantidade))
->map(fn (): string => strtoupper(
bin2hex(random_bytes(4)) . '-' . bin2hex(random_bytes(4))
))
->all();
}
public function consumirCodigoDeRecuperacao(Usuario $usuario, string $codigo): bool
{
$codigos = $usuario->dois_fatores_codigos_recuperacao ?? [];
$indice = array_search(strtoupper(trim($codigo)), $codigos, strict: true);
if ($indice === false) {
return false;
}
// Código de recuperação é de uso ÚNICO: some da lista assim que
// usado.
unset($codigos[$indice]);
$usuario->forceFill([
'dois_fatores_codigos_recuperacao' => array_values($codigos),
])->save();
return true;
}
}
// ---------------------------------------- app/Http/Controllers/Auth/LoginController.php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Usuario;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
public function entrar(Request $request): RedirectResponse
{
$dados = $request->validate([
'email' => ['required', 'email'],
'senha' => ['required'],
]);
// validate() confere a senha SEM autenticar. É a peça central do
// fluxo: com Auth::attempt() o usuário já entraria, e o segundo
// fator viraria enfeite.
if (!Auth::validate(['email' => $dados['email'], 'password' => $dados['senha']])) {
throw ValidationException::withMessages(['email' => 'Credenciais inválidas.']);
}
$usuario = Usuario::where('email', $dados['email'])->firstOrFail();
if (!$usuario->temDoisFatores()) {
Auth::login($usuario, $request->boolean('lembrar'));
$request->session()->regenerate();
return redirect()->intended(route('painel'));
}
// Guarda só o ID e um carimbo de tempo na sessão — nunca o objeto
// do usuário, e nunca a senha.
$request->session()->put('2fa.usuario_id', $usuario->id);
$request->session()->put('2fa.lembrar', $request->boolean('lembrar'));
$request->session()->put('2fa.expira_em', now()->addMinutes(5)->timestamp);
return redirect()->route('2fa.desafio');
}
}
// ---------------------------------------- app/Http/Controllers/Auth/DesafioController.php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Usuario;
use App\Services\DoisFatores;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class DesafioController extends Controller
{
public function __construct(private readonly DoisFatores $doisFatores) {}
public function mostrar(Request $request): View|RedirectResponse
{
return $this->usuarioPendente($request) === null
? redirect()->route('login')
: view('auth.2fa-desafio');
}
public function verificar(Request $request): RedirectResponse
{
$usuario = $this->usuarioPendente($request)
?? throw ValidationException::withMessages([
'codigo' => 'Sessão expirada. Faça o login de novo.',
]);
$request->validate(['codigo' => ['required', 'string']]);
// Seis dígitos são 1 milhão de combinações: sem limite, a força
// bruta acha o código em minutos.
$chave = "2fa:{$usuario->id}";
if (RateLimiter::tooManyAttempts($chave, maxAttempts: 5)) {
throw ValidationException::withMessages([
'codigo' => 'Muitas tentativas. Aguarde um minuto.',
]);
}
$codigo = str_replace([' ', '-'], '', (string) $request->input('codigo'));
// Código de 6 dígitos é TOTP; qualquer outro formato, tenta como
// código de recuperação.
$valido = preg_match('/^\d{6}$/', $codigo) === 1
? $this->doisFatores->verificar($usuario, $codigo)
: $this->doisFatores->consumirCodigoDeRecuperacao($usuario, $request->input('codigo'));
if (!$valido) {
RateLimiter::hit($chave, decaySeconds: 60);
throw ValidationException::withMessages(['codigo' => 'Código inválido.']);
}
RateLimiter::clear($chave);
$lembrar = (bool) $request->session()->pull('2fa.lembrar', false);
$request->session()->forget(['2fa.usuario_id', '2fa.expira_em']);
Auth::login($usuario, $lembrar);
$request->session()->regenerate();
return redirect()->intended(route('painel'));
}
private function usuarioPendente(Request $request): ?Usuario
{
$id = $request->session()->get('2fa.usuario_id');
$expira = (int) $request->session()->get('2fa.expira_em', 0);
// A janela expira: sem prazo, a sessão semi-autenticada ficaria
// aberta indefinidamente esperando o segundo fator.
if ($id === null || $expira < now()->timestamp) {
$request->session()->forget(['2fa.usuario_id', '2fa.lembrar', '2fa.expira_em']);
return null;
}
return Usuario::find($id);
}
}
// ---------------------------------------- app/Http/Controllers/DoisFatoresController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\DoisFatores;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class DoisFatoresController extends Controller
{
public function __construct(private readonly DoisFatores $doisFatores) {}
/** Passo 1: mostra o QR Code. Ainda NÃO ativa. */
public function iniciar(Request $request): View
{
$segredo = $this->doisFatores->gerarSegredo();
// Fica na sessão até a confirmação: gravar no usuário agora
// deixaria o 2FA meio ativo se a pessoa fechasse a aba.
$request->session()->put('2fa.segredo_pendente', $segredo);
return view('conta.2fa-ativar', [
'segredo' => $segredo,
'qrCode' => $this->doisFatores->qrCodeSvg($request->user(), $segredo),
]);
}
/** Passo 2: só ativa depois que o usuário PROVA que o app funciona. */
public function confirmar(Request $request): View
{
$request->validate(['codigo' => ['required', 'digits:6']]);
$segredo = $request->session()->get('2fa.segredo_pendente')
?? throw ValidationException::withMessages([
'codigo' => 'Recomece a ativação.',
]);
$usuario = $request->user();
$usuario->dois_fatores_segredo = $segredo;
if (!$this->doisFatores->verificar($usuario, $request->input('codigo'))) {
$usuario->dois_fatores_segredo = null;
throw ValidationException::withMessages([
'codigo' => 'Código incorreto. Confira o relógio do celular.',
]);
}
$codigos = $this->doisFatores->gerarCodigosDeRecuperacao();
$usuario->forceFill([
'dois_fatores_segredo' => $segredo,
'dois_fatores_codigos_recuperacao' => $codigos,
'dois_fatores_confirmado_em' => now(),
])->save();
$request->session()->forget('2fa.segredo_pendente');
// Os códigos aparecem UMA vez. Depois disso, nem o suporte
// consegue recuperá-los — estão cifrados e são de uso único.
return view('conta.2fa-codigos', compact('codigos'));
}
public function desativar(Request $request): RedirectResponse
{
// Exige a senha: sem isso, quem pegasse a sessão aberta desligaria
// o segundo fator em dois cliques.
$request->validate(['senha' => ['required', 'current_password']]);
$request->user()->forceFill([
'dois_fatores_segredo' => null,
'dois_fatores_codigos_recuperacao' => null,
'dois_fatores_confirmado_em' => null,
])->save();
return back()->with('sucesso', 'Autenticação de dois fatores desativada.');
}
}
// ---------------------------------------- routes/web.php
declare(strict_types=1);
use App\Http\Controllers\Auth\DesafioController;
use App\Http\Controllers\DoisFatoresController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function (): void {
Route::get('/2fa', [DesafioController::class, 'mostrar'])->name('2fa.desafio');
Route::post('/2fa', [DesafioController::class, 'verificar']);
});
Route::middleware(['auth', 'password.confirm'])->group(function (): void {
Route::get('/conta/2fa', [DoisFatoresController::class, 'iniciar'])->name('2fa.ativar');
Route::post('/conta/2fa', [DoisFatoresController::class, 'confirmar']);
Route::delete('/conta/2fa', [DoisFatoresController::class, 'desativar']);
});
// ---------------------------------------- tests/Feature/DoisFatoresTest.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Usuario;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
class DoisFatoresTest extends TestCase
{
use RefreshDatabase;
public function test_login_com_2fa_nao_autentica_direto(): void
{
$usuario = Usuario::factory()->create([
'senha' => Hash::make('segredo123'),
'dois_fatores_segredo' => (new Google2FA())->generateSecretKey(),
'dois_fatores_confirmado_em' => now(),
]);
$this->post('/login', ['email' => $usuario->email, 'senha' => 'segredo123'])
->assertRedirect(route('2fa.desafio'));
// A senha certa não basta: ainda não está autenticado.
$this->assertGuest();
}
public function test_codigo_totp_valido_completa_o_login(): void
{
$google2fa = new Google2FA();
$segredo = $google2fa->generateSecretKey();
$usuario = Usuario::factory()->create([
'senha' => Hash::make('segredo123'),
'dois_fatores_segredo' => $segredo,
'dois_fatores_confirmado_em' => now(),
]);
$this->post('/login', ['email' => $usuario->email, 'senha' => 'segredo123']);
$this->post('/2fa', ['codigo' => $google2fa->getCurrentOtp($segredo)])
->assertRedirect(route('painel'));
$this->assertAuthenticatedAs($usuario);
}
public function test_codigo_de_recuperacao_e_de_uso_unico(): void
{
$usuario = Usuario::factory()->create([
'senha' => Hash::make('segredo123'),
'dois_fatores_segredo' => (new Google2FA())->generateSecretKey(),
'dois_fatores_codigos_recuperacao' => ['AAAA1111-BBBB2222'],
'dois_fatores_confirmado_em' => now(),
]);
$this->post('/login', ['email' => $usuario->email, 'senha' => 'segredo123']);
$this->post('/2fa', ['codigo' => 'AAAA1111-BBBB2222'])->assertRedirect(route('painel'));
$this->assertSame([], $usuario->fresh()->dois_fatores_codigos_recuperacao);
// Segunda tentativa com o mesmo código: recusada.
$this->post('/logout');
$this->post('/login', ['email' => $usuario->email, 'senha' => 'segredo123']);
$this->post('/2fa', ['codigo' => 'AAAA1111-BBBB2222'])
->assertSessionHasErrors('codigo');
}
public function test_sessao_pendente_expira(): void
{
$usuario = Usuario::factory()->create([
'senha' => Hash::make('segredo123'),
'dois_fatores_segredo' => (new Google2FA())->generateSecretKey(),
'dois_fatores_confirmado_em' => now(),
]);
$this->post('/login', ['email' => $usuario->email, 'senha' => 'segredo123']);
$this->travel(6)->minutes();
$this->get('/2fa')->assertRedirect(route('login'));
}
}
Exercício 5
A ProdutoPolicy deste artigo tem view(User $user, Produto $produto) devolvendo $produto->ativo || $user->isAdmin() — a intenção sendo "produto ativo, qualquer um vê". Ponha a página de produto no ar sem estar logado e teste @can('view', $produto) num produto com ativo = true. O bloco aparece? Antes de olhar a resposta, formule a hipótese: o método devolveria true, então o link deveria aparecer. Depois descubra se o método chegou a ser chamado.
Ver resposta
✓ Resposta: O bloco não aparece, e o motivo é que view() nunca roda. Antes de chamar qualquer método de policy, o Gate olha para a assinatura: se o primeiro parâmetro não aceita null e não há usuário autenticado, ele devolve false direto, sem executar o método. Medido no illuminate/auth 13.32, com o mesmo produto e ninguém logado:
<?php
// Policy A — como está no artigo
public function view(User $user, Produto $produto): bool
{
return $produto->ativo || $user->isAdmin();
}
// Gate::allows('view', $produto) para visitante → false (método não chamado)
// Policy B — primeiro parâmetro anulável
public function view(?User $user, Produto $produto): bool
{
return $produto->ativo || $user?->isAdmin();
}
// Gate::allows('view', $produto) para visitante → true (método chamado)
Não é um caso de borda: é a decisão padrão do framework, e ela é defensiva por bom motivo. Uma policy escrita para usuário autenticado que recebesse null silenciosamente quebraria em $user->id ou, pior, passaria a autorizar. O ?User é a forma de dizer "esta regra pensou no visitante" — e aí a responsabilidade pelo null passa a ser sua, com $user?-> em todo acesso.
Duas consequências práticas, porque isso aparece longe da policy. A primeira: o @can numa view pública some sem deixar rastro, e o sintoma chega como "o botão não aparece para quem não está logado" — que é indistinguível de uma regra de negócio funcionando. A segunda: o before() da policy tem a mesma regra. Um before(User $user) não anulável não roda para visitante, então a liberação geral que ele daria não acontece. Ao escrever uma policy para recurso público, o par é obrigatório — ?User em before() e em cada método que o visitante pode alcançar.
Vale uma terceira observação, que separa autorização de autenticação: se a rota já passa pelo middleware auth, nada disso importa e o ?User só adiciona ruído. O caso existe exatamente onde a rota é aberta e a policy decide o que dentro dela é visível — a vitrine que mostra produto ativo para qualquer um e esconde o inativo de quem não é dono.