Autenticação e Autorização no Laravel

Autenticação e Autorização no Laravel

Login manual com Auth::attempt, middleware, Gates, Policies e o pacote da Spatie — cada camada com o que ela decide e o que passa adiante. Traz o método que sumiu do Controller base no Laravel 11 e a assinatura de policy que faz a regra nem ser consultada para visitante.
PHP

• • 37 min de leitura

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

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.

Comentários

Mais em PHP

Design Patterns: Singleton, Factory e Builder
Design Patterns: Singleton, Factory e Builder

Três padrões de criação e o que cada um custa: o Singleton, que garante…

APIs REST com PHP
APIs REST com PHP

Um roteador que traduz {param} em regex, um envelope JSON que o cliente…

PostgreSQL com PHP
PostgreSQL com PHP

Conectar é só trocar o DSN, mas o que vem depois prende: RETURNING, UPSERT com…