Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ MVP: `identity`, `community`, `branding`, `cfp`, `program`, `ticketing`, `paymen
Fase 2/3 (scaffold vazio, sem lógica ainda): `sponsors`, `suppliers`, `finance`, `tasks`, `engagement`.
Ver `docs/produto.md` na raiz do monorepo para a responsabilidade de cada um.

`identity` é o único com implementação real até agora: `users` (UUID, ver ADR 0007), cadastro com verificação de e-mail, login/logout via Sanctum, `GET /me`. Tenant (edição, papéis) é dos módulos Community (#9/#14), ainda não implementados — `/me` devolve `roles: []` até lá.

## Estrutura de cada módulo (`app-modules/<nome>/src/`)

| Pasta | O que vai lá |
Expand All @@ -31,6 +33,7 @@ Ver `docs/produto.md` na raiz do monorepo para a responsabilidade de cada um.
- Rota sensível (login, recuperação de senha, checkout, webhook) usa `throttle:<nome>` com um dos limiters nomeados em `App\Providers\SecurityServiceProvider` (`login`, `password-reset`, `checkout`, `webhooks`), configurável via `.env` (`RATE_LIMIT_*`).
- `Log::info/error/...` nunca precisa esconder segredo na mão — `App\Logging\RedactSensitiveDataProcessor` redige `password`/`token`/`api_key`/`secret`/`authorization`/cartão automaticamente em todo log (JSON estruturado, ver `config/logging.php`).
- Segredo nunca vai pro repositório — ver `docs/adr/0006-politica-de-segredos.md`.
- Macro/bind que uma rota de módulo usa (ex.: `Route::apiV1()`) tem que ser registrada no `register()` do provider, nunca no `boot()` — o `internachi/modular` carrega as rotas de cada módulo num hook `booting()` que dispara antes do `boot()` de qualquer provider (inclusive do `AppServiceProvider`).

## Comandos

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,23 @@

declare(strict_types=1);

namespace Database\Factories;
namespace PHPeste\Identity\Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use PHPeste\Identity\Models\User;

/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected $model = User::class;

protected static ?string $password;

/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
Expand All @@ -35,9 +32,6 @@ public function definition(): array
];
}

/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes): array => [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,16 @@

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->uuid('id')->primary();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('locale', 8)->default('pt_BR');
$table->string('photo_path')->nullable();
$table->rememberToken();
$table->timestamps();
});
Expand All @@ -28,24 +27,11 @@ public function up(): void
$table->string('token');
$table->timestamp('created_at')->nullable();
});

Schema::create('sessions', function (Blueprint $table): void {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
Schema::dropIfExists('users');
}
};
19 changes: 19 additions & 0 deletions backend/app-modules/identity/database/seeders/UserSeeder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace PHPeste\Identity\Database\Seeders;

use Illuminate\Database\Seeder;
use PHPeste\Identity\Models\User;

class UserSeeder extends Seeder
{
public function run(): void
{
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}
32 changes: 32 additions & 0 deletions backend/app-modules/identity/routes/identity-routes.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Route;
use PHPeste\Identity\Http\Controllers\AuthController;
use PHPeste\Identity\Http\Controllers\MeController;

Route::apiV1('auth')->group(function (): void {
Route::post('/register', [AuthController::class, 'register'])
->middleware('throttle:login')
->name('auth.register');

Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:login')
->name('auth.login');

Route::post('/logout', [AuthController::class, 'logout'])
->middleware('auth:sanctum')
->name('auth.logout');

Route::post('/logout-all', [AuthController::class, 'logoutAll'])
->middleware('auth:sanctum')
->name('auth.logout-all');

Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
->middleware('signed')
->name('verification.verify');

Route::post('/email/verification-notification', [AuthController::class, 'resendVerification'])
->middleware(['auth:sanctum', 'throttle:password-reset'])
->name('verification.send');
});

Route::apiV1()->middleware('auth:sanctum')->get('/me', MeController::class)->name('me');
22 changes: 22 additions & 0 deletions backend/app-modules/identity/src/Actions/AuthenticateUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace PHPeste\Identity\Actions;

use Illuminate\Auth\AuthenticationException;
use Illuminate\Support\Facades\Hash;
use Laravel\Sanctum\NewAccessToken;
use PHPeste\Identity\Models\User;

class AuthenticateUser
{
public function handle(string $email, string $password, string $deviceName): NewAccessToken
{
$user = User::where('email', $email)->first();

throw_if(!$user || !Hash::check($password, $user->password), AuthenticationException::class, 'Credenciais inválidas.');

return $user->createToken($deviceName);
}
}
26 changes: 26 additions & 0 deletions backend/app-modules/identity/src/Actions/RegisterUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace PHPeste\Identity\Actions;

use Illuminate\Auth\Events\Registered;
use PHPeste\Identity\Models\User;

class RegisterUser
{
public function handle(string $name, string $email, string $password): User
{
$user = User::create([
'name' => $name,
'email' => $email,
'password' => $password,
'locale' => app()->getLocale(),
]);

event(new Registered($user));
$user->sendEmailVerificationNotification();

return $user;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace PHPeste\Identity\Http\Controllers;

use Illuminate\Auth\Events\Verified;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
use PHPeste\Identity\Actions\AuthenticateUser;
use PHPeste\Identity\Actions\RegisterUser;
use PHPeste\Identity\Http\Requests\LoginRequest;
use PHPeste\Identity\Http\Requests\RegisterRequest;
use PHPeste\Identity\Http\Resources\UserResource;
use PHPeste\Identity\Models\User;

class AuthController
{
public function register(RegisterRequest $request, RegisterUser $registerUser): JsonResponse
{
$user = $registerUser->handle(
$request->string('name')->toString(),
$request->string('email')->toString(),
$request->string('password')->toString(),
);

return new UserResource($user)->response()->setStatusCode(201);
}

public function login(LoginRequest $request, AuthenticateUser $authenticateUser): JsonResponse
{
$deviceName = $request->string('device_name')->toString() ?: ($request->userAgent() ?? 'desconhecido');

$token = $authenticateUser->handle(
$request->string('email')->toString(),
$request->string('password')->toString(),
$deviceName,
);

/** @var User $user */
$user = $token->accessToken->tokenable;

return response()->json([
'user' => new UserResource($user)->resolve($request),
'token' => $token->plainTextToken,
]);
}

public function logout(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();

/** @var PersonalAccessToken $token */
$token = $user->currentAccessToken();
$token->delete();

return response()->json(['loggedOut' => true]);
}

public function logoutAll(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
$user->tokens()->delete();

return response()->json(['loggedOut' => true]);
}

/**
* A assinatura da URL (id + hash + expiração) já foi conferida pelo
* middleware `signed` antes de chegar aqui.
*/
public function verifyEmail(Request $request): JsonResponse
{
/** @var User $user */
$user = User::findOrFail($request->route('id'));

if (!$user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified($user));
}

return response()->json(['verified' => true]);
}

public function resendVerification(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();

if (!$user->hasVerifiedEmail()) {
$user->sendEmailVerificationNotification();
}

return response()->json(['sent' => true]);
}
}
28 changes: 28 additions & 0 deletions backend/app-modules/identity/src/Http/Controllers/MeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace PHPeste\Identity\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use PHPeste\Identity\Http\Resources\UserResource;
use PHPeste\Identity\Models\User;

class MeController
{
/**
* `roles` fica vazio até o módulo Community (#14) existir — é ele quem
* sabe em quais edições a pessoa tem papel.
*/
public function __invoke(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();

return response()->json([
'user' => new UserResource($user)->resolve($request),
'roles' => [],
]);
}
}
22 changes: 22 additions & 0 deletions backend/app-modules/identity/src/Http/Requests/LoginRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace PHPeste\Identity\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class LoginRequest extends FormRequest
{
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
'device_name' => ['sometimes', 'string', 'max:255'],
];
}
}
23 changes: 23 additions & 0 deletions backend/app-modules/identity/src/Http/Requests/RegisterRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace PHPeste\Identity\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;

class RegisterRequest extends FormRequest
{
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::min(10)->uncompromised()],
];
}
}
Loading
Loading