diff --git a/.env.example b/.env.example index a5ea69c82..00aa5a931 100644 --- a/.env.example +++ b/.env.example @@ -90,3 +90,7 @@ GITHUB_API_TOKEN= # Dev.to — slug da organização cujos artigos o contents:sync-articles busca. # A chave de API de cada pessoa é conectada pelo painel (/app/profile → Conexões), não aqui. DEVTO_ORG_SLUG=he4rt + +# API mobile (he4rt-app) — autenticação JWT, gerar com `php artisan jwt:secret`. +JWT_SECRET= +HE4RT_APP_DEEPLINK_SCHEME=he4rtapp diff --git a/.env.testing.example b/.env.testing.example index 88c9040eb..f58bf2083 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -81,3 +81,6 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" DISCORD_TOKEN= + +# API mobile (he4rt-app) — valor fixo, não é segredo de produção. +JWT_SECRET=testing-jwt-secret-do-not-use-in-production diff --git a/app-modules/docs/config/docs.php b/app-modules/docs/config/docs.php index a794db7b2..a513924b1 100644 --- a/app-modules/docs/config/docs.php +++ b/app-modules/docs/config/docs.php @@ -3,7 +3,7 @@ declare(strict_types=1); return [ - 'default_version' => '3.x', + 'default_version' => '4.x', 'cache' => [ 'enabled' => env('DOCS_CACHE_ENABLED', default: true), diff --git a/app-modules/docs/routes/docs-routes.php b/app-modules/docs/routes/docs-routes.php index 1a6fc7b7f..f5fa2f632 100644 --- a/app-modules/docs/routes/docs-routes.php +++ b/app-modules/docs/routes/docs-routes.php @@ -14,7 +14,7 @@ Route::get('docs', [DocsController::class, 'index'])->name('docs.index'); // The section is constrained to known document types so Scramble's -// `docs/3.x/api` (and any other prefix) falls through to its own route. +// `docs/4.x/api` (and any other prefix) falls through to its own route. Route::get('docs/{section}/{path?}', [DocsController::class, 'show']) ->where('section', $sections) ->where('path', '.*') diff --git a/app-modules/docs/src/DocsServiceProvider.php b/app-modules/docs/src/DocsServiceProvider.php index bc1a3c933..e4b15bbde 100644 --- a/app-modules/docs/src/DocsServiceProvider.php +++ b/app-modules/docs/src/DocsServiceProvider.php @@ -58,9 +58,9 @@ public function boot(): void { $this->commands([CacheDocsCommand::class]); - Scramble::registerApi('3.x'); + Scramble::registerApi('4.x'); - Scramble::registerUiRoute(path: 'docs/3.x/api', api: '3.x'); - Scramble::registerJsonSpecificationRoute(path: 'docs/3.x/swagger.json', api: '3.x'); + Scramble::registerUiRoute(path: 'docs/4.x/api', api: '4.x'); + Scramble::registerJsonSpecificationRoute(path: 'docs/4.x/swagger.json', api: '4.x'); } } diff --git a/app-modules/docs/src/Documentation.php b/app-modules/docs/src/Documentation.php index 37cfc6db5..02d435c3b 100644 --- a/app-modules/docs/src/Documentation.php +++ b/app-modules/docs/src/Documentation.php @@ -40,7 +40,7 @@ public static function replaceLinks($version, RenderedContentInterface|string $c public static function getDocVersions(): array { return [ - '3.x' => '3.x', + '4.x' => '4.x', ]; } diff --git a/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php b/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php index 902afd04d..99836932e 100644 --- a/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php +++ b/app-modules/docs/tests/Feature/Discovery/DocsRoutingTest.php @@ -46,7 +46,7 @@ it('does not let the catch-all hijack the Scramble api route', function (): void { $route = resolve(Router::class)->getRoutes()->match( - Request::create('/docs/3.x/api', 'GET'), + Request::create('/docs/4.x/api', 'GET'), ); expect($route->getActionName())->not->toContain(DocsController::class); diff --git a/app-modules/identity/routes/api-mobile-routes.php b/app-modules/identity/routes/api-mobile-routes.php new file mode 100644 index 000000000..62f393cee --- /dev/null +++ b/app-modules/identity/routes/api-mobile-routes.php @@ -0,0 +1,35 @@ +middleware('api') + ->group(static function (): void { + Route::prefix('auth')->group(static function (): void { + // O callback do OAuth é único por provider e já está cadastrado + // apontando pra rota web (auth/oauth/{provider} → OAuthController:: + // getAuthenticate), que também finaliza o login mobile quando + // intent=MobileLogin. Ver He4rt\Identity\Auth\Support\MobileOAuthDeepLink. + Route::get('/{provider}/redirect', [MobileOAuthController::class, 'redirect']) + ->name('mobile.oauth.redirect'); + + Route::post('/exchange', [MobileAuthController::class, 'exchange']) + ->name('mobile.auth.exchange'); + + Route::post('/refresh', [MobileAuthController::class, 'refresh']) + ->name('mobile.auth.refresh'); + + Route::post('/logout', [MobileAuthController::class, 'logout']) + ->middleware('auth:api') + ->name('mobile.auth.logout'); + }); + + Route::get('/me', MobileMeController::class) + ->middleware('auth:api') + ->name('mobile.me'); + }); diff --git a/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php b/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php new file mode 100644 index 000000000..bed7c3ef2 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php @@ -0,0 +1,44 @@ +get()) { + throw MobileAuthException::invalidExchangeCode(); + } + + try { + /** @var string|null $userId */ + $userId = Cache::get($cacheKey); + + throw_if($userId === null, MobileAuthException::invalidExchangeCode()); + + Cache::forget($cacheKey); + + $user = User::query()->find($userId); + + throw_if($user === null, MobileAuthException::invalidExchangeCode()); + + return $user; + } finally { + $lock->release(); + } + } +} diff --git a/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php index a073d4994..a60d3e12a 100644 --- a/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php +++ b/app-modules/identity/src/Auth/Actions/HandleOAuthCallbackAction.php @@ -34,7 +34,7 @@ public function execute(OAuthStateDTO $state, IdentityProvider $provider, string $oauthUser = $client->getAuthenticatedUser($access); $user = match ($state->intent) { - OAuthIntent::Login => $this->findOrCreateUser->execute($oauthUser), + OAuthIntent::Login, OAuthIntent::MobileLogin => $this->findOrCreateUser->execute($oauthUser), OAuthIntent::Link => $this->resolveAuthenticatedUser(), }; diff --git a/app-modules/identity/src/Auth/Actions/IssueMobileExchangeCodeAction.php b/app-modules/identity/src/Auth/Actions/IssueMobileExchangeCodeAction.php new file mode 100644 index 000000000..62e839f1c --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/IssueMobileExchangeCodeAction.php @@ -0,0 +1,28 @@ +id, self::TTL_SECONDS); + + return $code; + } +} diff --git a/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php b/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php new file mode 100644 index 000000000..d71635d51 --- /dev/null +++ b/app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php @@ -0,0 +1,28 @@ +login($user); + + return new MobileTokenDTO( + accessToken: $token, + tokenType: 'bearer', + expiresIn: config()->integer('jwt.ttl') * 60, + ); + } +} diff --git a/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php b/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php new file mode 100644 index 000000000..ee58d3d37 --- /dev/null +++ b/app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php @@ -0,0 +1,26 @@ + $this->accessToken, + 'token_type' => $this->tokenType, + 'expires_in' => $this->expiresIn, + ]; + } +} diff --git a/app-modules/identity/src/Auth/Enums/OAuthIntent.php b/app-modules/identity/src/Auth/Enums/OAuthIntent.php index 3431d31c7..90161d2df 100644 --- a/app-modules/identity/src/Auth/Enums/OAuthIntent.php +++ b/app-modules/identity/src/Auth/Enums/OAuthIntent.php @@ -8,4 +8,5 @@ enum OAuthIntent: string { case Login = 'login'; case Link = 'link'; + case MobileLogin = 'mobile_login'; } diff --git a/app-modules/identity/src/Auth/Exceptions/MobileAuthException.php b/app-modules/identity/src/Auth/Exceptions/MobileAuthException.php new file mode 100644 index 000000000..7a5113e09 --- /dev/null +++ b/app-modules/identity/src/Auth/Exceptions/MobileAuthException.php @@ -0,0 +1,15 @@ +validate([ + 'code' => ['required', 'string'], + ]); + + try { + $user = $exchangeCode->execute($request->string('code')->toString()); + } catch (MobileAuthException $mobileAuthException) { + return response()->json(['message' => $mobileAuthException->getMessage()], 401); + } + + return response()->json($issueToken->execute($user)->toArray()); + } + + /** + * Renovar token de acesso + * + * Emite um novo token a partir do token atual do header Authorization, + * mesmo que já tenha expirado — desde que dentro da janela de refresh + * (jwt.refresh_ttl) e não esteja na blacklist. + */ + public function refresh(): JsonResponse + { + /** @var JWTGuard $guard */ + $guard = Auth::guard('api'); + + try { + /** @var string $token */ + $token = $guard->refresh(); + } catch (JWTException $jwtException) { + return response()->json(['message' => $jwtException->getMessage()], 401); + } + + $refreshed = new MobileTokenDTO( + accessToken: $token, + tokenType: 'bearer', + expiresIn: config()->integer('jwt.ttl') * 60, + ); + + return response()->json($refreshed->toArray()); + } + + /** + * Encerrar sessão + * + * Invalida o token de acesso atual (blacklist) — o mesmo token não + * autentica nem renova depois disso. + */ + public function logout(): JsonResponse + { + Auth::guard('api')->logout(); + + return response()->json(status: 204); + } +} diff --git a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php new file mode 100644 index 000000000..a02119cab --- /dev/null +++ b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileMeController.php @@ -0,0 +1,30 @@ +user(); + + return response()->json([ + 'id' => $user->id, + 'username' => $user->username, + 'avatar_url' => $user->getFilamentAvatarUrl(), + ]); + } +} diff --git a/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php new file mode 100644 index 000000000..0dfebdd58 --- /dev/null +++ b/app-modules/identity/src/Auth/Http/Controllers/Mobile/MobileOAuthController.php @@ -0,0 +1,69 @@ + */ + private const array SUPPORTED_PROVIDERS = [ + IdentityProvider::Discord, + IdentityProvider::GitHub, + IdentityProvider::Twitch, + ]; + + /** + * Iniciar login OAuth + * + * Redireciona pro provider (discord, github ou twitch). O provider + * devolve o usuário pro callback web fixo, que redireciona de volta + * pro app via deep link com um código de troca de uso único — ver + * POST /api/mobile/auth/exchange. + */ + public function redirect(string $provider): RedirectResponse + { + $identityProvider = $this->resolveSupportedProvider($provider); + + try { + $client = $identityProvider->getClient(); + } catch (RuntimeException $runtimeException) { + Log::warning('Mobile OAuth client not configured', ['provider' => $provider, 'error' => $runtimeException->getMessage()]); + + return redirect()->to(MobileOAuthDeepLink::build('error', 'client_not_configured')); + } + + throw_unless($client instanceof OAuthClientContract, NotFoundHttpException::class); + + $state = new OAuthStateDTO( + intent: OAuthIntent::MobileLogin, + provider: $identityProvider, + panel: 'mobile', + ); + + return redirect()->to($client->redirectUrl($state)); + } + + private function resolveSupportedProvider(string $provider): IdentityProvider + { + $identityProvider = IdentityProvider::tryFrom($provider); + + throw_unless( + $identityProvider !== null && in_array($identityProvider, self::SUPPORTED_PROVIDERS, strict: true), + NotFoundHttpException::class, + ); + + return $identityProvider; + } +} diff --git a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php index a55cd9f8d..e59cacb21 100644 --- a/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php +++ b/app-modules/identity/src/Auth/Http/Controllers/OAuthController.php @@ -7,9 +7,11 @@ use App\Contracts\OAuthClientContract; use App\Http\Controllers\Controller; use He4rt\Identity\Auth\Actions\HandleOAuthCallbackAction; +use He4rt\Identity\Auth\Actions\IssueMobileExchangeCodeAction; use He4rt\Identity\Auth\DTOs\OAuthStateDTO; use He4rt\Identity\Auth\Enums\OAuthIntent; use He4rt\Identity\Auth\Exceptions\OAuthFlowException; +use He4rt\Identity\Auth\Support\MobileOAuthDeepLink; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; @@ -52,14 +54,15 @@ public function getAuthenticate(string $provider, HandleOAuthCallbackAction $act throw_if($identityProvider === null, NotFoundHttpException::class); $state = OAuthStateDTO::fromEncryptedString(request()->input('state')); + $isMobile = $state->intent === OAuthIntent::MobileLogin; $code = request()->input('code'); $oauthDenied = $code === null || request()->has('error'); if ($oauthDenied) { - $fallbackUrl = $state->returnUrl ?? '/'; - - return redirect()->to($fallbackUrl); + return $isMobile + ? redirect()->to(MobileOAuthDeepLink::build('error', 'access_denied')) + : redirect()->to($state->returnUrl ?? '/'); } try { @@ -67,7 +70,15 @@ public function getAuthenticate(string $provider, HandleOAuthCallbackAction $act } catch (OAuthFlowException $oAuthFlowException) { Log::warning('OAuth flow failed', ['provider' => $provider, 'error' => $oAuthFlowException->getMessage()]); - return redirect()->to($state->returnUrl ?? '/'); + return $isMobile + ? redirect()->to(MobileOAuthDeepLink::build('error', 'oauth_flow_failed')) + : redirect()->to($state->returnUrl ?? '/'); + } + + if ($isMobile) { + $exchangeCode = resolve(IssueMobileExchangeCodeAction::class)->execute($result->user); + + return redirect()->to(MobileOAuthDeepLink::build('callback', code: $exchangeCode)); } if ($result->hasMergeConflict()) { diff --git a/app-modules/identity/src/Auth/Support/MobileOAuthDeepLink.php b/app-modules/identity/src/Auth/Support/MobileOAuthDeepLink.php new file mode 100644 index 000000000..b0990cf34 --- /dev/null +++ b/app-modules/identity/src/Auth/Support/MobileOAuthDeepLink.php @@ -0,0 +1,16 @@ + $error, 'code' => $code]); + + return sprintf('%s://oauth/%s%s', $scheme, $path, $query === [] ? '' : '?'.http_build_query($query)); + } +} diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index da61c76ec..024614682 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -30,6 +30,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use PHPOpenSourceSaver\JWTAuth\Contracts\JWTSubject; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Permission\Models\Role; @@ -54,7 +55,7 @@ #[UseFactory(factoryClass: UserFactory::class)] #[Table(name: 'users')] #[Hidden('password', 'remember_token', 'email_verified_at')] -final class User extends Authenticatable implements FilamentUser, HasMedia, HasName +final class User extends Authenticatable implements FilamentUser, HasMedia, HasName, JWTSubject { use HasAddress; /** @use HasFactory */ @@ -71,6 +72,19 @@ public function isSuperAdmin(): bool return $this->hasRole(UserRole::SuperAdmin); } + public function getJWTIdentifier(): string + { + return $this->getKey(); + } + + /** + * @return array + */ + public function getJWTCustomClaims(): array + { + return []; + } + /** * @return MorphMany */ diff --git a/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php b/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php new file mode 100644 index 000000000..9e280e143 --- /dev/null +++ b/app-modules/identity/tests/Feature/Auth/MobileAuthControllerTest.php @@ -0,0 +1,84 @@ +create(); + $code = resolve(IssueMobileExchangeCodeAction::class)->execute($user); + + $this->postJson('/api/mobile/auth/exchange', ['code' => $code]) + ->assertOk() + ->assertJsonStructure(['access_token', 'token_type', 'expires_in']) + ->assertJson(['token_type' => 'bearer']); +}); + +test('exchange consumes the code, so it cannot be reused', function (): void { + $user = User::factory()->create(); + $code = resolve(IssueMobileExchangeCodeAction::class)->execute($user); + + $this->postJson('/api/mobile/auth/exchange', ['code' => $code])->assertOk(); + $this->postJson('/api/mobile/auth/exchange', ['code' => $code])->assertUnauthorized(); +}); + +test('exchange rejects a concurrent redemption of the same code', function (): void { + $user = User::factory()->create(); + $code = resolve(IssueMobileExchangeCodeAction::class)->execute($user); + + // Simula um segundo request concorrente já segurando o lock antes do + // primeiro conseguir ler+apagar o código — prova que a troca é atômica. + $lock = Cache::lock('identity:mobile-oauth-exchange-lock:'.$code, 10); + $lock->get(); + + $this->postJson('/api/mobile/auth/exchange', ['code' => $code])->assertUnauthorized(); + + $lock->release(); +}); + +test('exchange rejects an unknown code', function (): void { + $this->postJson('/api/mobile/auth/exchange', ['code' => 'does-not-exist']) + ->assertUnauthorized(); +}); + +test('me returns the authenticated user', function (): void { + $user = User::factory()->create(['username' => 'he4rtdev']); + $token = Auth::guard('api')->login($user); + + $this->getJson('/api/mobile/me', ['Authorization' => "Bearer {$token}"]) + ->assertOk() + ->assertJson(['id' => $user->id, 'username' => 'he4rtdev']); +}); + +test('me rejects a request without a token', function (): void { + $this->getJson('/api/mobile/me')->assertUnauthorized(); +}); + +test('refresh issues a new token', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + + $response = $this->postJson('/api/mobile/auth/refresh', [], ['Authorization' => "Bearer {$token}"]) + ->assertOk() + ->assertJsonStructure(['access_token', 'token_type', 'expires_in']); + + expect($response->json('access_token'))->not->toBe($token); +}); + +test('refresh rejects a missing token', function (): void { + $this->postJson('/api/mobile/auth/refresh')->assertUnauthorized(); +}); + +test('logout invalidates the token', function (): void { + $user = User::factory()->create(); + $token = Auth::guard('api')->login($user); + + $this->postJson('/api/mobile/auth/logout', [], ['Authorization' => "Bearer {$token}"]) + ->assertNoContent(); + + $this->getJson('/api/mobile/me', ['Authorization' => "Bearer {$token}"]) + ->assertUnauthorized(); +}); diff --git a/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php b/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php new file mode 100644 index 000000000..1eadc41d6 --- /dev/null +++ b/app-modules/identity/tests/Feature/Auth/MobileOAuthControllerTest.php @@ -0,0 +1,125 @@ +instance(GitHubOAuthClient::class, new readonly class($access, $user) implements OAuthClientContract + { + public function __construct( + private OAuthAccessDTO $access, + private OAuthUserDTO $user, + ) {} + + public function redirectUrl(?OAuthStateDTO $state = null): string + { + return 'https://github.test/oauth'; + } + + public function auth(string $code): OAuthAccessDTO + { + return $this->access; + } + + public function getAuthenticatedUser(OAuthAccessDTO $credentials): OAuthUserDTO + { + return $this->user; + } + }); +} + +test('redirect sends an unsupported provider to a 404', function (): void { + $this->get('/api/mobile/auth/devto/redirect')->assertNotFound(); +}); + +test('redirect forwards to the provider authorize URL', function (): void { + bindMobileGithubClient(); + + $this->get('/api/mobile/auth/github/redirect') + ->assertRedirect('https://github.test/oauth'); +}); + +test('a denied mobile authorization on the shared web callback redirects to the app deep link with an error', function (): void { + // Discord/GitHub/Twitch só conhecem UMA redirect_uri por app: a rota web + // /auth/oauth/{provider} (OAuthController::getAuthenticate). O login mobile + // é distinguido pelo intent codificado no state, não por uma rota própria. + $state = new OAuthStateDTO( + intent: OAuthIntent::MobileLogin, + provider: IdentityProvider::GitHub, + panel: 'mobile', + returnUrl: 'mobile', + ); + + $response = $this->get('/auth/oauth/github?'.http_build_query([ + 'state' => (string) $state, + 'error' => 'access_denied', + ])); + + $response->assertRedirect(); + expect($response->headers->get('Location')) + ->toStartWith('he4rtapp://oauth/error') + ->toContain('error=access_denied'); +}); + +test('a successful mobile login on the shared web callback redirects to the app deep link with an exchange code, without starting a web session', function (): void { + bindMobileGithubClient(); + + $state = new OAuthStateDTO( + intent: OAuthIntent::MobileLogin, + provider: IdentityProvider::GitHub, + panel: 'mobile', + returnUrl: 'mobile', + ); + + $response = $this->get('/auth/oauth/github?'.http_build_query([ + 'state' => (string) $state, + 'code' => 'auth-code', + ])); + + $response->assertRedirect(); + + $location = (string) $response->headers->get('Location'); + + expect($location)->toStartWith('he4rtapp://oauth/callback?code=') + ->and(User::query()->where('username', 'mobile-user')->exists())->toBeTrue() + ->and(Auth::check())->toBeFalse(); +}); diff --git a/app-modules/profile/routes/api-mobile-routes.php b/app-modules/profile/routes/api-mobile-routes.php new file mode 100644 index 000000000..2e7824d78 --- /dev/null +++ b/app-modules/profile/routes/api-mobile-routes.php @@ -0,0 +1,13 @@ +middleware(['api', 'auth:api']) + ->group(static function (): void { + Route::get('/profile', MobileProfileController::class) + ->name('mobile.profile.show'); + }); diff --git a/app-modules/profile/src/Http/Controllers/Mobile/MobileProfileController.php b/app-modules/profile/src/Http/Controllers/Mobile/MobileProfileController.php new file mode 100644 index 000000000..fad2fa022 --- /dev/null +++ b/app-modules/profile/src/Http/Controllers/Mobile/MobileProfileController.php @@ -0,0 +1,35 @@ +user()->id; + + $profile = Profile::ensureExists($userId) + ->load(['profileSkills.skill', 'workExperiences']); + + // ensureExists() pode ter acabado de criar o profile (firstOrCreate); + // ResourceResponse herdaria o 201 do model pra uma rota GET. + return new ProfileResource($profile) + ->response() + ->setStatusCode(Response::HTTP_OK); + } +} diff --git a/app-modules/profile/src/Http/Resources/ProfileResource.php b/app-modules/profile/src/Http/Resources/ProfileResource.php new file mode 100644 index 000000000..7e9c3c158 --- /dev/null +++ b/app-modules/profile/src/Http/Resources/ProfileResource.php @@ -0,0 +1,51 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'nickname' => $this->nickname, + 'headline' => $this->headline, + 'about' => $this->about, + 'seniority_level' => $this->seniority_level?->value, + 'years_experience' => $this->years_experience, + 'social_links' => $this->social_links ?? [], + 'available_for_proposals' => $this->available_for_proposals, + 'start_availability' => $this->start_availability?->value, + 'expected_salary_min' => $this->expected_salary_min, + 'expected_salary_max' => $this->expected_salary_max, + 'preferences' => $this->preferences->toArray(), + 'skills' => $this->profileSkills->map(static fn ($profileSkill): array => [ + 'id' => $profileSkill->skill->id, + 'name' => $profileSkill->skill->name, + 'category' => $profileSkill->skill->category->value, + 'proficiency' => $profileSkill->proficiency->value, + 'years_experience' => $profileSkill->years_experience, + ])->all(), + 'work_experiences' => $this->workExperiences->map(static fn ($experience): array => [ + 'id' => $experience->id, + 'company_name' => $experience->company_name, + 'position' => $experience->position, + 'description' => $experience->description, + 'start_date' => $experience->start_date->toDateString(), + 'end_date' => $experience->end_date?->toDateString(), + 'is_currently_working_here' => $experience->is_currently_working_here, + ])->all(), + ]; + } +} diff --git a/app-modules/profile/tests/Feature/Http/MobileProfileControllerTest.php b/app-modules/profile/tests/Feature/Http/MobileProfileControllerTest.php new file mode 100644 index 000000000..ffb200d0a --- /dev/null +++ b/app-modules/profile/tests/Feature/Http/MobileProfileControllerTest.php @@ -0,0 +1,63 @@ +getJson('/api/mobile/profile') + ->assertUnauthorized(); +}); + +it('creates the profile on first access when it does not exist yet', function (): void { + $user = User::factory()->create(); + Profile::query()->where('user_id', $user->id)->delete(); + + $token = Auth::guard('api')->login($user); + + $this->getJson('/api/mobile/profile', ['Authorization' => "Bearer {$token}"]) + ->assertOk() + ->assertJson([ + 'data' => [ + 'nickname' => null, + 'available_for_proposals' => false, + 'skills' => [], + 'work_experiences' => [], + ], + ]); + + expect(Profile::query()->where('user_id', $user->id)->exists())->toBeTrue(); +}); + +it('returns the full profile with skills and work experiences', function (): void { + $user = User::factory()->create(); + + $profile = Profile::factory() + ->for($user) + ->complete() + ->withSkills(2) + ->create(); + + $profile->workExperiences()->create([ + 'company_name' => 'He4rt Devs', + 'position' => 'Backend Engineer', + 'description' => 'Cuidando da API mobile.', + 'start_date' => '2023-01-01', + 'end_date' => null, + 'is_currently_working_here' => true, + ]); + + $token = Auth::guard('api')->login($user); + + $response = $this->getJson('/api/mobile/profile', ['Authorization' => "Bearer {$token}"]) + ->assertOk(); + + $response->assertJsonPath('data.nickname', $profile->nickname) + ->assertJsonPath('data.headline', $profile->headline) + ->assertJsonPath('data.seniority_level', $profile->seniority_level->value) + ->assertJsonCount(2, 'data.skills') + ->assertJsonPath('data.work_experiences.0.company_name', 'He4rt Devs') + ->assertJsonPath('data.work_experiences.0.is_currently_working_here', true); +}); diff --git a/bootstrap/app.php b/bootstrap/app.php index 86c27f279..afd8a9ef4 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -24,6 +24,20 @@ $middleware->web(append: [ SetApplicationLocale::class, ]); + + // O app não tem rota "login" — auth é só via OAuth (Filament cuida do + // próprio redirect nos painéis). Sem isso, o middleware `auth`/`auth:api` + // tenta redirect(route('login')) pra qualquer client sem "Accept: + // application/json" e quebra com 500 (RouteNotFoundException). + $middleware->redirectGuestsTo(redirect: null); + }) + ->withExceptions(static function (Exceptions $exceptions): void { + // Sem isso, um cliente de API que não manda "Accept: application/json" + // (curl puro, a maioria dos clientes HTTP mobile) recebe um 500 em vez + // de 401/erro em JSON: o handler padrão tenta redirect(route('login')), + // que não existe neste app — login é só via OAuth. + $exceptions->shouldRenderJsonWhen( + fn ($request, $throwable): bool => $request->is('api/*') || $request->expectsJson(), + ); }) - ->withExceptions(static function (Exceptions $exceptions): void {}) ->create(); diff --git a/composer.json b/composer.json index 2deb4fd03..01dd84bf7 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "monicahq/laravel-cloudflare": "^4.1", "owenvoke/blade-fontawesome": "^3.3.1", "phiki/phiki": "^2.2.1", + "php-open-source-saver/jwt-auth": "^2.9", "ryangjchandler/commonmark-blade-block": "^1.1.1", "saloonphp/saloon": "^4.0.1", "spatie/laravel-backup": "^10.3.2", diff --git a/composer.lock b/composer.lock index bf6cb9e98..5b06f5955 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "25af23518b24f0cb163d843d734df60b", + "content-hash": "0316001caf3e07b15459586cc128ac57", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -277,23 +277,24 @@ }, { "name": "brick/math", - "version": "0.18.0", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + "reference": "2effe05d2177c451b86c6a073196a4034c02f211" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211", + "reference": "2effe05d2177c451b86c6a073196a4034c02f211", "shasum": "" }, "require": { "php": "^8.2" }, "require-dev": { - "phpstan/phpstan": "2.1.22", + "phpstan/phpstan": "2.2.13", + "phpstan/phpstan-phpunit": "2.0.18", "phpunit/phpunit": "^11.5" }, "type": "library", @@ -324,7 +325,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.18.0" + "source": "https://github.com/brick/math/tree/1.0.0" }, "funding": [ { @@ -332,7 +333,7 @@ "type": "github" } ], - "time": "2026-06-14T18:21:03+00:00" + "time": "2026-09-12T10:28:18+00:00" }, { "name": "calebporzio/sushi", @@ -1867,27 +1868,25 @@ }, { "name": "doctrine/lexer", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c", + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5", - "psalm/plugin-phpunit": "^0.18.3", - "vimeo/psalm": "^5.21" + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" }, "type": "library", "autoload": { @@ -1924,7 +1923,7 @@ ], "support": { "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.0.1" + "source": "https://github.com/doctrine/lexer/tree/3.0.2" }, "funding": [ { @@ -1940,7 +1939,7 @@ "type": "tidelift" } ], - "time": "2024-02-05T11:56:58+00:00" + "time": "2026-06-14T20:44:06+00:00" }, { "name": "dragonmantank/cron-expression", @@ -4484,20 +4483,20 @@ }, { "name": "laravel/framework", - "version": "v13.31.0", + "version": "v13.33.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "7c75fbf93f91fa077d3df1c820cc14f4e59a9774" + "reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/7c75fbf93f91fa077d3df1c820cc14f4e59a9774", - "reference": "7c75fbf93f91fa077d3df1c820cc14f4e59a9774", + "url": "https://api.github.com/repos/laravel/framework/zipball/91188a17ceaa3dbace6e8a5f7abd0d042e466359", + "reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359", "shasum": "" }, "require": { - "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19", + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -4612,15 +4611,17 @@ "orchestra/testbench-core": "^11.0.0", "pda/pheanstalk": "^7.0.0 || ^8.0.0", "php-http/discovery": "^1.15", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.2.14", "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", "predis/predis": "^2.3 || ^3.0", "rector/rector": "2.6.3", "resend/resend-php": "^1.0", "symfony/cache": "^7.4.0 || ^8.0.0", "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/mercure": "^0.8.0", "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", - "symfony/translation": "^7.4.0 || ^8.0.0" + "symfony/translation": "^7.4.0 || ^8.0.0", + "web-token/jwt-library": "^4.1" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", @@ -4656,8 +4657,10 @@ "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).", "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).", + "web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)." }, "type": "library", "extra": { @@ -4707,7 +4710,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-09-08T14:22:55+00:00" + "time": "2026-09-22T14:12:33+00:00" }, { "name": "laravel/head", @@ -5054,16 +5057,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.16", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", - "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", "shasum": "" }, "require": { @@ -5111,7 +5114,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-08-18T20:28:54+00:00" + "time": "2026-09-22T14:32:34+00:00" }, { "name": "laravel/telescope", @@ -5250,18 +5253,91 @@ }, "time": "2026-03-17T14:54:13+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "league/commonmark", - "version": "2.10.1", + "version": "2.10.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e" + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e", - "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f", + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f", "shasum": "" }, "require": { @@ -5355,7 +5431,7 @@ "type": "tidelift" } ], - "time": "2026-09-07T13:44:26+00:00" + "time": "2026-09-21T13:07:34+00:00" }, { "name": "league/config", @@ -6771,18 +6847,85 @@ ], "time": "2026-08-11T10:17:44+00:00" }, + { + "name": "namshi/jose", + "version": "7.2.3", + "source": { + "type": "git", + "url": "https://github.com/namshi/jose.git", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/namshi/jose/zipball/89a24d7eb3040e285dd5925fcad992378b82bcff", + "reference": "89a24d7eb3040e285dd5925fcad992378b82bcff", + "shasum": "" + }, + "require": { + "ext-date": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-pcre": "*", + "ext-spl": "*", + "php": ">=5.5", + "symfony/polyfill-php56": "^1.0" + }, + "require-dev": { + "phpseclib/phpseclib": "^2.0", + "phpunit/phpunit": "^4.5|^5.0", + "satooshi/php-coveralls": "^1.0" + }, + "suggest": { + "ext-openssl": "Allows to use OpenSSL as crypto engine.", + "phpseclib/phpseclib": "Allows to use Phpseclib as crypto engine, use version ^2.0." + }, + "type": "library", + "autoload": { + "psr-4": { + "Namshi\\JOSE\\": "src/Namshi/JOSE/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Nadalin", + "email": "alessandro.nadalin@gmail.com" + }, + { + "name": "Alessandro Cinelli (cirpo)", + "email": "alessandro.cinelli@gmail.com" + } + ], + "description": "JSON Object Signing and Encryption library for PHP.", + "keywords": [ + "JSON Web Signature", + "JSON Web Token", + "JWS", + "json", + "jwt", + "token" + ], + "support": { + "issues": "https://github.com/namshi/jose/issues", + "source": "https://github.com/namshi/jose/tree/master" + }, + "time": "2016-12-05T07:27:31+00:00" + }, { "name": "nesbot/carbon", - "version": "3.13.2", + "version": "3.14.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" + "reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", - "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2", + "reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2", "shasum": "" }, "require": { @@ -6874,7 +7017,7 @@ "type": "tidelift" } ], - "time": "2026-08-08T11:40:35+00:00" + "time": "2026-09-12T20:45:19+00:00" }, { "name": "nette/php-generator", @@ -7598,6 +7741,99 @@ ], "time": "2026-07-22T19:51:40+00:00" }, + { + "name": "php-open-source-saver/jwt-auth", + "version": "v2.9.3", + "source": { + "type": "git", + "url": "https://github.com/PHP-Open-Source-Saver/jwt-auth.git", + "reference": "1bdb72de5e60fcb02264d7aba4bd4608939e3798" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-Open-Source-Saver/jwt-auth/zipball/1bdb72de5e60fcb02264d7aba4bd4608939e3798", + "reference": "1bdb72de5e60fcb02264d7aba4bd4608939e3798", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/auth": "^12|^13", + "illuminate/contracts": "^12|^13", + "illuminate/http": "^12|^13", + "illuminate/support": "^12|^13", + "lcobucci/jwt": "^5.4", + "namshi/jose": "^7.0", + "nesbot/carbon": "^2.0|^3.0", + "php": "^8.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "illuminate/console": "^12|^13", + "illuminate/routing": "^12|^13", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^10|^11", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5|^11" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "JWTAuth": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth", + "JWTFactory": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory" + }, + "providers": [ + "PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "PHPOpenSourceSaver\\JWTAuth\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sean Tymon", + "email": "tymon148@gmail.com", + "homepage": "https://tymon.xyz", + "role": "Forked package creator | Developer" + }, + { + "name": "Eric Schricker", + "email": "eric.schricker@adiutabyte.de", + "role": "Developer" + }, + { + "name": "Fabio William Conceição", + "email": "messhias@gmail.com", + "role": "Developer" + }, + { + "name": "Max Snow", + "email": "contact@maxsnow.me", + "role": "Developer" + } + ], + "description": "JSON Web Token Authentication for Laravel and Lumen", + "homepage": "https://github.com/PHP-Open-Source-Saver/jwt-auth", + "keywords": [ + "Authentication", + "JSON Web Token", + "auth", + "jwt", + "laravel" + ], + "support": { + "issues": "https://github.com/PHP-Open-Source-Saver/jwt-auth/issues", + "source": "https://github.com/PHP-Open-Source-Saver/jwt-auth" + }, + "time": "2026-08-21T05:00:05+00:00" + }, { "name": "phpoption/phpoption", "version": "1.10.0", @@ -8575,20 +8811,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.3", + "version": "4.9.4", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -8647,9 +8883,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "name": "ratchet/pawl", @@ -11137,16 +11373,16 @@ }, { "name": "symfony/console", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac" + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/eb7d9957d66739649e931ce7a9d05dab69f8abac", - "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac", + "url": "https://api.github.com/repos/symfony/console/zipball/29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", "shasum": "" }, "require": { @@ -11213,7 +11449,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.6" + "source": "https://github.com/symfony/console/tree/v8.1.7" }, "funding": [ { @@ -11233,7 +11469,7 @@ "type": "tidelift" } ], - "time": "2026-08-25T14:18:42+00:00" + "time": "2026-09-13T10:55:57+00:00" }, { "name": "symfony/css-selector", @@ -11765,16 +12001,16 @@ }, { "name": "symfony/finder", - "version": "v8.1.5", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8d7acede2b2ae07605783d1c43e49b5767036474" + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8d7acede2b2ae07605783d1c43e49b5767036474", - "reference": "8d7acede2b2ae07605783d1c43e49b5767036474", + "url": "https://api.github.com/repos/symfony/finder/zipball/4fbe46a3eb64abf8a57f0364075b91f4a233e062", + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062", "shasum": "" }, "require": { @@ -11809,7 +12045,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.5" + "source": "https://github.com/symfony/finder/tree/v8.1.7" }, "funding": [ { @@ -11829,7 +12065,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:16:08+00:00" + "time": "2026-09-10T19:14:39+00:00" }, { "name": "symfony/html-sanitizer", @@ -11905,16 +12141,16 @@ }, { "name": "symfony/http-foundation", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb" + "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/093b78326f649c3a9db922b9f17123b6aeb3b8fb", - "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", + "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", "shasum": "" }, "require": { @@ -11962,7 +12198,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.6" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.7" }, "funding": [ { @@ -11982,20 +12218,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:55+00:00" + "time": "2026-09-14T17:47:24+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355" + "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", - "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", + "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", "shasum": "" }, "require": { @@ -12072,7 +12308,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.6" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.7" }, "funding": [ { @@ -12092,20 +12328,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T21:40:49+00:00" + "time": "2026-09-15T07:12:52+00:00" }, { "name": "symfony/mailer", - "version": "v8.1.5", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "89f43137da74b8f1aab37c99926482b7084f51b9" + "reference": "8783380ecdafa23d36fc90c5873fd44b635c6e10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/89f43137da74b8f1aab37c99926482b7084f51b9", - "reference": "89f43137da74b8f1aab37c99926482b7084f51b9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/8783380ecdafa23d36fc90c5873fd44b635c6e10", + "reference": "8783380ecdafa23d36fc90c5873fd44b635c6e10", "shasum": "" }, "require": { @@ -12152,7 +12388,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.1.5" + "source": "https://github.com/symfony/mailer/tree/v8.1.7" }, "funding": [ { @@ -12172,20 +12408,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-09-15T06:01:24+00:00" }, { "name": "symfony/mime", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f" + "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", - "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", + "url": "https://api.github.com/repos/symfony/mime/zipball/773ac57f20e2795bdadb65b5b1b5f4850d498283", + "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283", "shasum": "" }, "require": { @@ -12238,7 +12474,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.6" + "source": "https://github.com/symfony/mime/tree/v8.1.7" }, "funding": [ { @@ -12258,7 +12494,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T09:06:25+00:00" + "time": "2026-09-04T11:02:17+00:00" }, { "name": "symfony/options-resolver", @@ -12753,6 +12989,74 @@ ], "time": "2026-05-27T06:59:30+00:00" }, + { + "name": "symfony/polyfill-php56", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php56.git", + "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", + "reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "metapackage", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + }, + "branch-alias": { + "dev-main": "1.20-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 5.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php56/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, { "name": "symfony/polyfill-php73", "version": "v1.37.0", @@ -13322,16 +13626,16 @@ }, { "name": "symfony/process", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d863f5e70d7c87abb906ac11b61f83036093000b" + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d863f5e70d7c87abb906ac11b61f83036093000b", - "reference": "d863f5e70d7c87abb906ac11b61f83036093000b", + "url": "https://api.github.com/repos/symfony/process/zipball/10823b09358e690df4ff943e24e8492bb1019fc3", + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3", "shasum": "" }, "require": { @@ -13363,7 +13667,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.1.6" + "source": "https://github.com/symfony/process/tree/v8.1.7" }, "funding": [ { @@ -13383,7 +13687,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-09-02T12:40:29+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -13642,16 +13946,16 @@ }, { "name": "symfony/string", - "version": "v8.1.2", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c", + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c", "shasum": "" }, "require": { @@ -13708,7 +14012,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.2" + "source": "https://github.com/symfony/string/tree/v8.1.7" }, "funding": [ { @@ -13728,7 +14032,7 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:35:25+00:00" + "time": "2026-09-11T14:51:16+00:00" }, { "name": "symfony/translation", @@ -13985,16 +14289,16 @@ }, { "name": "symfony/var-dumper", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "3783365b58972f4779254d98372af80fbf15e170" + "reference": "741a8c4c948b0bb9bd3653daacd3644c73c40ea5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3783365b58972f4779254d98372af80fbf15e170", - "reference": "3783365b58972f4779254d98372af80fbf15e170", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/741a8c4c948b0bb9bd3653daacd3644c73c40ea5", + "reference": "741a8c4c948b0bb9bd3653daacd3644c73c40ea5", "shasum": "" }, "require": { @@ -14048,7 +14352,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.6" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.7" }, "funding": [ { @@ -14068,7 +14372,7 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:55+00:00" + "time": "2026-09-03T16:02:27+00:00" }, { "name": "symfony/yaml", diff --git a/config/auth.php b/config/auth.php index 18a46a52f..9c49b178b 100644 --- a/config/auth.php +++ b/config/auth.php @@ -44,6 +44,11 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'api' => [ + 'driver' => 'jwt', + 'provider' => 'users', + ], ], /* diff --git a/config/jwt.php b/config/jwt.php new file mode 100644 index 000000000..49ad96366 --- /dev/null +++ b/config/jwt.php @@ -0,0 +1,326 @@ + env('JWT_SECRET'), + + /* + |-------------------------------------------------------------------------- + | JWT Authentication Keys + |-------------------------------------------------------------------------- + | + | The algorithm you are using, will determine whether your tokens are + | signed with a random string (defined in `JWT_SECRET`) or using the + | following public & private keys. + | + | Symmetric Algorithms: + | HS256, HS384 & HS512 will use `JWT_SECRET`. + | + | Asymmetric Algorithms: + | RS256, RS384 & RS512 / ES256, ES384 & ES512 will use the keys below. + | + */ + + 'keys' => [ + /* + |-------------------------------------------------------------------------- + | Public Key + |-------------------------------------------------------------------------- + | + | A path or resource to your public key. + | + | E.g. 'file://path/to/public/key' + | + */ + + 'public' => env('JWT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Private Key + |-------------------------------------------------------------------------- + | + | A path or resource to your private key. + | + | E.g. 'file://path/to/private/key' + | + */ + + 'private' => env('JWT_PRIVATE_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passphrase + |-------------------------------------------------------------------------- + | + | The passphrase for your private key. Can be null if none set. + | + */ + + 'passphrase' => env('JWT_PASSPHRASE'), + ], + + /* + |-------------------------------------------------------------------------- + | JWT time to live + |-------------------------------------------------------------------------- + | + | Specify the length of time (in minutes) that the token will be valid for. + | Defaults to 1 hour. + | + | You can also set this to null, to yield a never expiring token. + | Some people may want this behaviour for e.g. a mobile app. + | This is not particularly recommended, so make sure you have appropriate + | systems in place to revoke the token if necessary. + | Notice: If you set this to null you should remove 'exp' element from 'required_claims' list. + | + */ + + 'ttl' => (int) env('JWT_TTL', 60), + + /* + |-------------------------------------------------------------------------- + | Refresh time to live + |-------------------------------------------------------------------------- + | + | Specify the length of time (in minutes) that the token can be refreshed within. + | This defines the refresh window, during which the user can refresh their token + | before re-authentication is required. + | + | By default, a refresh will NOT issue a new "iat" (issued at) timestamp. If changed + | to true, each refresh will issue a new "iat" timestamp, extending the refresh + | period from the most recent refresh. This results in a rolling refresh + | + | To retain a fluid refresh window from the last refresh action (i.e., the behavior between + | version 2.5.0 and 2.8.2), set "refresh_iat" to true. With this setting, the refresh + | window will renew with each subsequent refresh. + | + | The refresh ttl defaults to 2 weeks. + | + | You can also set this to null, to yield an infinite refresh time. + | Some may want this instead of never expiring tokens for e.g. a mobile app. + | This is not particularly recommended, so make sure you have appropriate + | systems in place to revoke the token if necessary. + | + */ + + 'refresh_iat' => env('JWT_REFRESH_IAT', default: false), + 'refresh_ttl' => (int) env('JWT_REFRESH_TTL', 20_160), + + /* + |-------------------------------------------------------------------------- + | JWT hashing algorithm + |-------------------------------------------------------------------------- + | + | Specify the hashing algorithm that will be used to sign the token. + | + | See here: https://github.com/namshi/jose/tree/master/src/Namshi/JOSE/Signer/OpenSSL + | for possible values. + | + */ + + 'algo' => env('JWT_ALGO', 'HS256'), + + /* + |-------------------------------------------------------------------------- + | Required Claims + |-------------------------------------------------------------------------- + | + | Specify the required claims that must exist in any token. + | A TokenInvalidException will be thrown if any of these claims are not + | present in the payload. + | + */ + + 'required_claims' => [ + 'iss', + 'iat', + 'exp', + 'nbf', + 'sub', + 'jti', + ], + + /* + |-------------------------------------------------------------------------- + | Persistent Claims + |-------------------------------------------------------------------------- + | + | Specify the claim keys to be persisted when refreshing a token. + | `sub` and `iat` will automatically be persisted, in + | addition to the these claims. + | + | Note: If a claim does not exist then it will be ignored. + | + */ + + 'persistent_claims' => [ + // 'foo', + // 'bar', + ], + + /* + |-------------------------------------------------------------------------- + | Lock Subject + |-------------------------------------------------------------------------- + | + | This will determine whether a `prv` claim is automatically added to + | the token. The purpose of this is to ensure that if you have multiple + | authentication models e.g. `App\User` & `App\OtherPerson`, then we + | should prevent one authentication request from impersonating another, + | if 2 tokens happen to have the same id across the 2 different models. + | + | Under specific circumstances, you may want to disable this behaviour + | e.g. if you only have one authentication model, then you would save + | a little on token size. + | + */ + + 'lock_subject' => true, + + /* + |-------------------------------------------------------------------------- + | Leeway + |-------------------------------------------------------------------------- + | + | This property gives the jwt timestamp claims some "leeway". + | Meaning that if you have any unavoidable slight clock skew on + | any of your servers then this will afford you some level of cushioning. + | + | This applies to the claims `iat`, `nbf` and `exp`. + | + | Specify in seconds - only if you know you need it. + | + */ + + 'leeway' => (int) env('JWT_LEEWAY', 0), + + /* + |-------------------------------------------------------------------------- + | Blacklist Enabled + |-------------------------------------------------------------------------- + | + | In order to invalidate tokens, you must have the blacklist enabled. + | If you do not want or need this functionality, then set this to false. + | + */ + + 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', default: true), + + /* + | ------------------------------------------------------------------------- + | Blacklist Grace Period + | ------------------------------------------------------------------------- + | + | When multiple concurrent requests are made with the same JWT, + | it is possible that some of them fail, due to token regeneration + | on every request. + | + | Set grace period in seconds to prevent parallel request failure. + | + */ + + 'blacklist_grace_period' => (int) env('JWT_BLACKLIST_GRACE_PERIOD', 0), + + /* + |-------------------------------------------------------------------------- + | Show blacklisted token option + |-------------------------------------------------------------------------- + | + | Specify if you want to show black listed token exception on the laravel logs. + | + */ + + 'show_black_list_exception' => env('JWT_SHOW_BLACKLIST_EXCEPTION', default: true), + + /* + |-------------------------------------------------------------------------- + | Cookies encryption + |-------------------------------------------------------------------------- + | + | By default Laravel encrypt cookies for security reason. + | If you decide to not decrypt cookies, you will have to configure Laravel + | to not encrypt your cookie token by adding its name into the $except + | array available in the middleware "EncryptCookies" provided by Laravel. + | see https://laravel.com/docs/master/responses#cookies-and-encryption + | for details. + | + | Set it to true if you want to decrypt cookies. + | + */ + + 'decrypt_cookies' => false, + + /* + |-------------------------------------------------------------------------- + | Cookie key name + |-------------------------------------------------------------------------- + | + | Specify the cookie key name that you would like to use for the cookie token. + | + */ + + 'cookie_key_name' => 'token', + + /* + |-------------------------------------------------------------------------- + | Providers + |-------------------------------------------------------------------------- + | + | Specify the various providers used throughout the package. + | + */ + + 'providers' => [ + /* + |-------------------------------------------------------------------------- + | JWT Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to create and decode the tokens. + | + */ + + 'jwt' => Lcobucci::class, + + /* + |-------------------------------------------------------------------------- + | Authentication Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to authenticate users. + | + */ + + 'auth' => Illuminate::class, + + /* + |-------------------------------------------------------------------------- + | Storage Provider + |-------------------------------------------------------------------------- + | + | Specify the provider that is used to store tokens in the blacklist. + | + */ + + 'storage' => PHPOpenSourceSaver\JWTAuth\Providers\Storage\Illuminate::class, + ], +]; diff --git a/config/scramble.php b/config/scramble.php index 750b8ecc0..019f9664d 100644 --- a/config/scramble.php +++ b/config/scramble.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess; +use Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy; return [ /* @@ -26,7 +27,7 @@ /* * API version. */ - 'version' => '3.x', + 'version' => '4.x', /* * Description rendered on the home page of the API documentation (`/docs/api`). @@ -134,5 +135,12 @@ RestrictedDocsAccess::class, ], + /* + * Marca como "bearer" as rotas protegidas por qualquer guard "auth:*" + * (ex.: auth:api, o guard JWT da API mobile) — sem isso, os endpoints + * protegidos aparecem na doc como se não precisassem de autenticação. + */ + 'security_strategy' => MiddlewareAuthSecurityStrategy::class, + 'extensions' => [], ]; diff --git a/config/services.php b/config/services.php index b56da8e11..dc425d3ca 100644 --- a/config/services.php +++ b/config/services.php @@ -72,4 +72,8 @@ 'openai' => [ 'api_key' => env('OPENAI_API_KEY'), ], + + 'he4rt_app' => [ + 'deeplink_scheme' => env('HE4RT_APP_DEEPLINK_SCHEME', 'he4rtapp'), + ], ]; diff --git a/docs/plans/2026-09-22-api-mobile-jwt.md b/docs/plans/2026-09-22-api-mobile-jwt.md new file mode 100644 index 000000000..22d3226c5 --- /dev/null +++ b/docs/plans/2026-09-22-api-mobile-jwt.md @@ -0,0 +1,180 @@ +--- +type: plan +title: 'API mobile do He4rt App — autenticação JWT e endpoints por feature' +module: identity, events, activity, profile +status: proposed +date: 2026-09-22 +author: tecrodrigocastro +related: + prd: he4rt/heartdevs.com#531 +--- + +# Plano — API mobile pro He4rt App (JWT) + +**Goal:** nascer a API que o [he4rt/he4rt-app](https://github.com/he4rt/he4rt-app) (NativePHP Mobile, repo separado) vai consumir por HTTP, cobrindo as três áreas do `panel-app` que o PRD pede: Timeline, Eventos (com check-in) e Perfil — autenticada por JWT, não Sanctum. + +**Por que JWT em vez de Sanctum:** o PoC anterior (documentado no PS da issue #531) já validou Sanctum funcionando (endpoint `/api/mobile/me` autenticado). A troca não é por limitação técnica do Sanctum — é para abrir a porta a **claims customizadas no próprio token** (role, tenant, capabilities) sem precisar de uma query extra por request, o que o Sanctum não oferece nativamente (seus tokens são opacos; abilities existem, mas não claims arbitrárias no payload). v1 usa claims mínimas (`sub`, `iat`, `exp`); o espaço pra crescer fica reservado via `JWTSubject::getJWTCustomClaims()`. + +**Pacote:** [`php-open-source-saver/jwt-auth`](https://github.com/PHP-Open-Source-Saver/jwt-auth) `^2.9` — fork ativo do `tymon/jwt-auth` original (parado), com suporte confirmado a Laravel `^12|^13` e PHP `^8.3`. Compatível com o stack atual (Laravel 13.25, PHP 8.4). + +**O que herdamos do PoC anterior e precisa ser desfeito:** a branch `poc/nativephp-mobile` (PR aberto em `tecrodrigocastro/heartdevs.com#1`) tem `HasApiTokens` no `User`, guard `sanctum` em `config/auth.php`, a migration de `personal_access_tokens` pra UUID, e `MobileMeController`/`api-mobile-routes.php` em `identity`. Esse trabalho vira **ponto de partida** da Feature 0 abaixo, mas o guard e o mecanismo de token trocam de Sanctum pra JWT — não dá pra só mergear como está. + +--- + +## Onde a API vive + +O PRD deixa isso como pergunta em aberto. Recomendação, com precedente já criado pelo próprio PoC: + +**Cada módulo de domínio dono do recurso expõe sua própria fatia da API mobile — sem módulo novo.** + +``` +app-modules/{modulo}/ +├── routes/api-mobile-routes.php <- prefixo /api/mobile/{recurso}, middleware auth:api (JWT) +└── src/Http/Controllers/Mobile/ <- controllers finos, só serializam o retorno de Actions existentes +``` + +Por quê: nenhuma das quatro features abaixo precisa compor domínio de MAIS de um módulo dentro do mesmo endpoint — Timeline só usa `activity`, Eventos só usa `events`, Perfil só usa `profile`, Auth só usa `identity`. `auth()->user()`/`auth()->id()` (guard JWT) resolve a identidade em qualquer um deles sem import cruzado. Isso respeita a regra do `CONTEXT-MAP.md` ("a API depende do domínio, nunca o contrário") sem inventar uma categoria de módulo nova (nem Domain, nem Integration, nem Presentation encaixam perfeitamente num módulo "api-mobile" dedicado). + +Se alguma feature futura precisar compor dois domínios num único payload (ex.: notificação cruzando Events + Activity), aí sim vale reabrir essa decisão. + +--- + +## Arquitetura de autenticação (Feature 0) + +### Guard novo + +`config/auth.php` ganha: + +```php +'guards' => [ + 'api' => ['driver' => 'jwt', 'provider' => 'users'], +], +``` + +`User` (`app-modules/identity/src/User/Models/User.php`) implementa `Tymon\JWTAuth\Contracts\JWTSubject` (nome do namespace do pacote pode variar — conferir na doc do `php-open-source-saver/jwt-auth` no momento de implementar): `getJWTIdentifier()` retorna `$this->getKey()` (UUID), `getJWTCustomClaims()` retorna `[]` na v1. + +### Fluxo OAuth → JWT + +O login web hoje (`OAuthController::getAuthenticate`) termina em `Auth::login()` (sessão) + redirect pro painel Filament. Isso não serve pro mobile — o app não tem sessão, e o controller depende de `filament()->setCurrentPanel()`. Em vez de reescrever esse fluxo, ele nasce **paralelo**, reaproveitando a resolução de usuário: + +1. App abre `Browser::auth()` (plugin do NativePHP) apontando pra `GET /api/mobile/auth/{provider}/redirect` (novo endpoint em `identity`, análogo ao `OAuthController::getRedirect` mas sem depender de painel Filament). +2. Provider (Discord/GitHub/Twitch — os três já suportados via `IdentityProvider::supportedProviders()`) redireciona pro callback do OAuth. +3. **Implementado diferente do rascunho inicial**: Discord/GitHub/Twitch só aceitam UMA `redirect_uri` fixa por app, cadastrada apontando pro callback web (`/auth/oauth/{provider}` → `OAuthController::getAuthenticate`) — não dava pra ter uma rota de callback mobile própria. O callback web compartilhado passou a distinguir por `OAuthIntent::MobileLogin` (lido do `state`) e, quando é esse o caso, gera um **código de troca de uso único** (curto, ~60s de TTL, guardado em cache) e redireciona pro deep link do app em vez de fazer `Auth::login()`. +4. App recebe o deep link (`he4rtapp://oauth/callback?code=...`), extrai o `code`, faz `POST /api/mobile/auth/exchange` com esse código. +5. Endpoint valida o código (uso único, expira, invalida-se após o uso — troca é atômica via `Cache::lock()`, não só `Cache::pull()`), emite `{ access_token, token_type, expires_in }` via `php-open-source-saver/jwt-auth`. + +**Por que um código de troca em vez do JWT direto no deep link:** deep links (e o histórico de URLs do SO) não são um lugar seguro pra um token de longa duração passar. O código de troca é de uso único e vive segundos — se vazar, não serve pra nada depois do primeiro uso. + +### Refresh + +**Implementado diferente do rascunho inicial**: não existe um `refresh_token` separado — `php-open-source-saver/jwt-auth` renova o próprio access token via `JWTGuard::refresh()`, aceitando um token já expirado desde que dentro da janela `jwt.refresh_ttl` e fora da blacklist. `POST /api/mobile/auth/refresh` manda o token atual no header `Authorization` (sem middleware `auth:api`, que rejeitaria um token expirado antes mesmo do controller rodar) e devolve um novo `{ access_token, token_type, expires_in }`. `POST /api/mobile/auth/logout` invalida o token atual via blacklist. + +### Endpoints da Feature 0 + +| Método | Rota | Descrição | +| ------ | -------------------------------------- | ------------------------------------------------------------------------------- | +| GET | `/api/mobile/auth/{provider}/redirect` | Inicia OAuth (Discord/GitHub/Twitch) | +| POST | `/api/mobile/auth/exchange` | Código de troca → token JWT | +| POST | `/api/mobile/auth/refresh` | Token atual (mesmo expirado, dentro da janela) → token novo | +| POST | `/api/mobile/auth/logout` | Invalida o token atual (blacklist) | +| GET | `/api/mobile/me` | Usuário autenticado (id, username, avatar) — já existe no PoC, só troca o guard | + +Não existe rota de callback mobile própria — o callback do provider bate direto em `/auth/oauth/{provider}` (rota web já existente), ver passo 3 acima. + +--- + +## Feature 1 — Timeline + +Domínio: `He4rt\Activity\Timeline\*` (já existe, reaproveitado sem alteração). + +| Método | Rota | Action reaproveitada | +| ------ | -------------------------------------- | ---------------------------------------------------------------------------------------- | +| GET | `/api/mobile/timeline` | `TimelineFeed::builder()` (paginação simples, mesmo padrão do `Feed.php` do `panel-app`) | +| POST | `/api/mobile/timeline` | `CreatePost` + `CreatePostDTO` | +| POST | `/api/mobile/timeline/{post}/replies` | `CreateReply` + `CreateReplyDTO` | +| DELETE | `/api/mobile/timeline/replies/{reply}` | `DeleteReply` | + +Sem gap de domínio — é serialização pura em cima do que já existe. O corpo de resposta precisa de um API Resource novo (`TimelinePostResource`) já que a UI mobile não usa view Blade; padrão Eloquent API Resource, conforme `laravel/core` guideline deste repo. + +**Fora do v1, decisão explícita**: reações (`withCount('reactions')` aparece no `Feed.php`) — o PRD não menciona reagir como escopo v1 do app; incluir a contagem na resposta é grátis, mas o endpoint de reagir fica pra depois se a issue não abrir esse escopo. + +--- + +## Feature 2 — Eventos + +Domínio: `He4rt\Events\*`. + +| Método | Rota | Action reaproveitada | +| ------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/mobile/events` | `Event::query()->viewableByParticipant()` (scope já existe, usado no `EventDetail.php`) | +| GET | `/api/mobile/events/{event}` | idem + `enrollmentPolicy`, computa os mesmos booleans do `EventDetail.php` (`canApply`, `canConfirmPresence`, `isEventFull`) num Resource | +| POST | `/api/mobile/events/{event}/enroll` | `EnrollUserAction` + `EnrollUserDTO` (RSVP e Application, mesma Action cobre os dois) | +| GET | `/api/mobile/events/{event}/qr` | Serializa `$enrollment->qrToken->token` (dado cru — o app renderiza o QR nativamente, não precisa do SVG que o Livewire gera) | +| POST | `/api/mobile/events/{event}/check-in` | `NumericCodeCheckInAction` (ver gap abaixo pro método QR) | + +### Gap de domínio encontrado: não existe self-check-in por QR + +O PRD pede "check-in por QR code (via Scanner) com o código numérico como alternativa" — ou seja, os dois métodos alimentando a **mesma ação de self-check-in**, só mudando a forma de entrada (câmera vs teclado). Investigando o domínio (`app-modules/events/src/CheckIn/`): + +- `NumericCodeCheckInAction` — **self-service** (`TriggeredBy::User`, ator = o próprio dono da enrollment), valida contra `CheckInCode` (código compartilhado por evento/dia, com expiração e limite de usos). +- `QrCheckInAction` — **existe, mas é outra coisa**: é o staff escaneando o QR pessoal do participante (`TriggeredBy::Admin` fixo no código, valida contra `QrToken`, token 1:1 com a enrollment). É provavelmente o que alimenta um scanner no `panel-admin`, não o app do participante. + +Não existe hoje uma ação self-service equivalente à `NumericCodeCheckInAction` que aceite entrada por QR. Duas formas de fechar esse gap (decisão a bater com o time antes de codar a Feature 2): + +1. **QR só codifica o mesmo código compartilhado** (`CheckInCode.code`) — o venue projeta o código como texto E como QR na mesma tela; o Scanner do app só preenche o campo automaticamente. Nesse caso não precisa de Action nova: o endpoint aceita o valor decodificado como se fosse digitado, chama `NumericCodeCheckInAction` do mesmo jeito, e o `method` gravado no `CheckIn` continua sendo `NumericCode` (ou passa a receber o `method` como parâmetro pra registrar `QrCode` de verdade — mudança pequena na Action/DTO). +2. **QR é uma entidade própria** (novo token, não o `CheckInCode` compartilhado) — precisa de uma `QrCodeSelfCheckInAction` nova, espelhando a `NumericCodeCheckInAction` mas validando um token diferente. Mais trabalho, mais uma tabela ou reuso do `QrToken` com uma regra nova de quem pode consumir (hoje `QrToken` é pensado pra ser consumido por um `Admin`, não pelo próprio dono). + +Recomendação: opção 1 é bem mais barata e resolve o que o PRD pede ("QR como alternativa ao numérico", não "QR como terceiro sistema"). Fica registrado aqui pra não repetir a investigação — mas é uma decisão de produto, não só técnica, então deveria ser confirmada na issue antes da Feature 2 entrar em código. + +--- + +## Feature 3 — Perfil + +Domínio: `He4rt\Profile\*`. + +| Método | Rota | Action/Model reaproveitado | +| ------ | --------------------- | --------------------------------------------------------------- | +| GET | `/api/mobile/profile` | `Profile::ensureExists(auth()->id())` — mesmo padrão usado hoje | + +v1 é só leitura (o PRD explicitamente escopa "visualização do próprio perfil" — editar fica de fora). Um `ProfileResource` serializa os campos públicos do model (nickname, headline, seniority, social_links, etc.) — sem gap de domínio, `UpsertProfile`/`SyncProfileSkills` já existem se a edição entrar em escopo depois. + +**Status: implementado** (`He4rt\Profile\Http\Controllers\Mobile\MobileProfileController` + `Http\Resources\ProfileResource`, também inclui `profileSkills.skill` e `workExperiences`). Rota registrada em `app-modules/profile/routes/api-mobile-routes.php`. + +--- + +## Fora de escopo (herdado do PRD, sem mudança) + +- Reaproveitar as classes Livewire do `panel-app` como UI — arquitetura do NativePHP Mobile não permite. +- Funcionalidade de domínio nova além do que já existe no `panel-app` (reações no feed, edição de perfil, etc.) — a não ser que a issue #531 seja atualizada pra abrir esse escopo. +- Notificações push — mesma pendência do PRD original, sem decisão ainda. +- Publicação nas lojas — fora do escopo desta API. + +--- + +## Riscos e perguntas em aberto + +1. **Gap do QR self-check-in (Feature 2)** — decisão de produto, não só técnica; ver seção acima. +2. **Deep link scheme** (`NATIVEPHP_DEEPLINK_SCHEME`) — precisa ser registrado no `he4rt-app` e o valor comunicado pra esta API configurar o redirect do passo 3 do fluxo OAuth. +3. **Rate limiting da API mobile** — os endpoints de escrita (postar, check-in) precisam de throttle próprio, análogo ao `RateLimiter` que `NumericCodeCheckIn.php` já usa no Livewire; replicar o mesmo limite na Action ou no middleware da rota. +4. **Blacklist do refresh token** — `php-open-source-saver/jwt-auth` precisa de um storage pra blacklist (cache/DB); confirmar qual driver de cache este projeto já usa em produção antes de habilitar `JWT_BLACKLIST_ENABLED`. +5. **Claims futuras** — o PRD não pede isso agora; só está sendo deixado como gancho arquitetural (`getJWTCustomClaims()`). Não implementar claims de role/tenant sem um caso de uso concreto puxando. + +--- + +## Ordem de implementação sugerida + +1. **Feature 0 (Auth)** — bloqueia todo o resto; sem token, nenhuma outra feature autentica. +2. **Feature 3 (Perfil)** — menor superfície, bom smoke test do guard `api` novo de ponta a ponta antes de features com escrita. +3. **Feature 1 (Timeline)** — leitura + escrita simples (post/reply), sem gap de domínio. +4. **Feature 2 (Eventos)** — a mais complexa (enrollment + dois métodos de check-in); decisão do gap de QR (seção acima) deveria estar fechada antes de começar. + +Cada feature vira sua própria branch/PR neste repo (`heartdevs.com`), seguindo a convenção `feature/` ou `story/531-` já documentada em `.ai/rules`. O client (`he4rt-app`) consome cada endpoint conforme ele fica pronto — não precisa esperar a API inteira pra começar a integrar a Feature 0/3. + +--- + +## PS: achados da revisão de segurança (CodeRabbit) + +A implementação da Feature 0 passou por revisão automática de segurança antes do merge, que achou dois pontos reais no fluxo de troca de código: + +- **Race condition na troca do código** (corrigido): `Cache::pull()` do Laravel é `get()` + `forget()` como duas chamadas separadas, não atômicas — duas requisições concorrentes com o mesmo código podiam ler o valor antes de qualquer uma apagar, mintando dois tokens da mesma autorização. `ExchangeMobileCodeAction` passou a usar `Cache::lock()` pra serializar leitura+remoção por código. +- **Deep link com custom scheme pode ser sequestrado** (débito técnico conhecido, não fechado nesta PR): `he4rtapp://oauth/callback?code=...` usa um esquema de URL customizado, que não é exclusivo do app — outro app instalado no mesmo aparelho pode registrar o mesmo scheme e interceptar o código antes do app legítimo (TTL curto e uso único não impedem isso, já que o atacante só precisa ser o primeiro a usar). A correção correta é **PKCE** (o app mobile gera um `code_verifier` local, manda só o hash `code_challenge` no redirect, e precisa do verifier original pra completar a troca depois) ou um HTTPS App Link verificado em vez do scheme customizado. Não implementado agora porque depende do `he4rt-app` (ainda só um scaffold) também mudar o lado dele — fica registrado aqui pra não ser esquecido antes do app mobile começar a consumir esse fluxo de verdade. diff --git a/resources/docs/3.x/convencoes-de-codigo.md b/resources/docs/4.x/convencoes-de-codigo.md similarity index 100% rename from resources/docs/3.x/convencoes-de-codigo.md rename to resources/docs/4.x/convencoes-de-codigo.md diff --git a/resources/docs/3.x/documentation.md b/resources/docs/4.x/documentation.md similarity index 100% rename from resources/docs/3.x/documentation.md rename to resources/docs/4.x/documentation.md diff --git a/resources/docs/3.x/installation.md b/resources/docs/4.x/installation.md similarity index 98% rename from resources/docs/3.x/installation.md rename to resources/docs/4.x/installation.md index b07f23426..3ee69b2ed 100644 --- a/resources/docs/3.x/installation.md +++ b/resources/docs/4.x/installation.md @@ -14,7 +14,7 @@ order: 1 ## Versioning Scheme -A documentação do portal é versionada por linha de release (por exemplo, `3.x`). +A documentação do portal é versionada por linha de release (por exemplo, `4.x`). Os arquivos vivem em `resources/docs/{version}/` e os links internos usam o placeholder `{{version}}` para apontar sempre para a versão corrente — assim a mesma página funciona em qualquer linha de release. diff --git a/resources/docs/3.x/primeiro-pull-request.md b/resources/docs/4.x/primeiro-pull-request.md similarity index 100% rename from resources/docs/3.x/primeiro-pull-request.md rename to resources/docs/4.x/primeiro-pull-request.md diff --git a/resources/docs/3.x/releases.md b/resources/docs/4.x/releases.md similarity index 94% rename from resources/docs/3.x/releases.md rename to resources/docs/4.x/releases.md index eece8c215..45028eb71 100644 --- a/resources/docs/3.x/releases.md +++ b/resources/docs/4.x/releases.md @@ -19,7 +19,7 @@ linha de release (`MAJOR.MINOR.PATCH`): - **MINOR** — funcionalidades novas retrocompatíveis. - **PATCH** — correções de bug retrocompatíveis. -A documentação acompanha a linha **MAJOR.x** (por exemplo, `3.x`). Os links entre +A documentação acompanha a linha **MAJOR.x** (por exemplo, `4.x`). Os links entre páginas usam o placeholder `{{version}}` para resolver sempre a versão corrente. diff --git a/resources/docs/3.x/rodando-o-projeto.md b/resources/docs/4.x/rodando-o-projeto.md similarity index 100% rename from resources/docs/3.x/rodando-o-projeto.md rename to resources/docs/4.x/rodando-o-projeto.md