Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .env.testing.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion app-modules/docs/config/docs.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion app-modules/docs/routes/docs-routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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', '.*')
Expand Down
6 changes: 3 additions & 3 deletions app-modules/docs/src/DocsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
2 changes: 1 addition & 1 deletion app-modules/docs/src/Documentation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions app-modules/identity/routes/api-mobile-routes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

use He4rt\Identity\Auth\Http\Controllers\Mobile\MobileAuthController;
use He4rt\Identity\Auth\Http\Controllers\Mobile\MobileMeController;
use He4rt\Identity\Auth\Http\Controllers\Mobile\MobileOAuthController;
use Illuminate\Support\Facades\Route;

Route::prefix('api/mobile')
->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');
});
44 changes: 44 additions & 0 deletions app-modules/identity/src/Auth/Actions/ExchangeMobileCodeAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\Actions;

use He4rt\Identity\Auth\Exceptions\MobileAuthException;
use He4rt\Identity\User\Models\User;
use Illuminate\Support\Facades\Cache;

final readonly class ExchangeMobileCodeAction
{
public function execute(string $code): User
{
$cacheKey = IssueMobileExchangeCodeAction::cacheKey($code);

// Cache::pull() é get()+forget() como duas chamadas separadas — sob
// concorrência, dois requests podem ler o mesmo código antes de
// qualquer um apagar e mintar dois tokens da mesma autorização. O
// lock serializa get+forget num bloco atômico por código.
$lock = Cache::lock('identity:mobile-oauth-exchange-lock:'.$code, 10);

if (!$lock->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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\Actions;

use He4rt\Identity\User\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;

final readonly class IssueMobileExchangeCodeAction
{
private const int TTL_SECONDS = 60;

public static function cacheKey(string $code): string
{
return "identity:mobile-oauth-exchange:{$code}";
}

public function execute(User $user): string
{
$code = Str::random(40);

Cache::put(self::cacheKey($code), $user->id, self::TTL_SECONDS);

return $code;
}
}
28 changes: 28 additions & 0 deletions app-modules/identity/src/Auth/Actions/IssueMobileTokenAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\Actions;

use He4rt\Identity\Auth\DTOs\MobileTokenDTO;
use He4rt\Identity\User\Models\User;
use Illuminate\Support\Facades\Auth;
use PHPOpenSourceSaver\JWTAuth\JWTGuard;

final readonly class IssueMobileTokenAction
{
public function execute(User $user): MobileTokenDTO
{
/** @var JWTGuard $guard */
$guard = Auth::guard('api');

/** @var string $token */
$token = $guard->login($user);

return new MobileTokenDTO(
accessToken: $token,
tokenType: 'bearer',
expiresIn: config()->integer('jwt.ttl') * 60,
);
}
}
26 changes: 26 additions & 0 deletions app-modules/identity/src/Auth/DTOs/MobileTokenDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\DTOs;

final readonly class MobileTokenDTO
{
public function __construct(
public string $accessToken,
public string $tokenType,
public int $expiresIn,
) {}

/**
* @return array{access_token: string, token_type: string, expires_in: int}
*/
public function toArray(): array
{
return [
'access_token' => $this->accessToken,
'token_type' => $this->tokenType,
'expires_in' => $this->expiresIn,
];
}
}
1 change: 1 addition & 0 deletions app-modules/identity/src/Auth/Enums/OAuthIntent.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ enum OAuthIntent: string
{
case Login = 'login';
case Link = 'link';
case MobileLogin = 'mobile_login';
}
15 changes: 15 additions & 0 deletions app-modules/identity/src/Auth/Exceptions/MobileAuthException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\Exceptions;

use RuntimeException;

final class MobileAuthException extends RuntimeException
{
public static function invalidExchangeCode(): self
{
return new self('Invalid or expired exchange code.');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\Http\Controllers\Mobile;

use App\Http\Controllers\Controller;
use He4rt\Identity\Auth\Actions\ExchangeMobileCodeAction;
use He4rt\Identity\Auth\Actions\IssueMobileTokenAction;
use He4rt\Identity\Auth\DTOs\MobileTokenDTO;
use He4rt\Identity\Auth\Exceptions\MobileAuthException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use PHPOpenSourceSaver\JWTAuth\Exceptions\JWTException;
use PHPOpenSourceSaver\JWTAuth\JWTGuard;

final class MobileAuthController extends Controller
{
/**
* Trocar código de login por token
*
* Troca o código de uso único devolvido no deep link do OAuth (ver
* MobileOAuthController::redirect) por um par de token de acesso JWT.
* O código expira em 60s e só pode ser usado uma vez.
*/
public function exchange(Request $request, ExchangeMobileCodeAction $exchangeCode, IssueMobileTokenAction $issueToken): JsonResponse
{
$request->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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\Http\Controllers\Mobile;

use App\Http\Controllers\Controller;
use He4rt\Identity\User\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class MobileMeController extends Controller
{
/**
* Usuário autenticado
*
* Retorna os dados básicos do usuário dono do token JWT atual.
*/
public function __invoke(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();

return response()->json([
'id' => $user->id,
'username' => $user->username,
'avatar_url' => $user->getFilamentAvatarUrl(),
]);
}
}
Loading
Loading