stage 1 of oauth removal, switch to sanctum pat tokens
This commit is contained in:
184
app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php
Normal file
184
app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\TenantAdminTokenRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantAdminTokenController extends Controller
|
||||
{
|
||||
public function store(TenantAdminTokenRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->credentials();
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = User::query()->when(
|
||||
isset($credentials['email']),
|
||||
fn ($query) => $query->where('email', $credentials['email']),
|
||||
fn ($query) => $query->where('username', $credentials['username'] ?? null)
|
||||
)->first();
|
||||
|
||||
if (! $user || ! Hash::check($credentials['password'], (string) $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'login' => [trans('auth.failed')],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'login' => [trans('auth.not_authorized')],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($user->email_verified_at === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'login' => [trans('auth.unverified')],
|
||||
]);
|
||||
}
|
||||
|
||||
[$token, $abilities] = $this->issueTokenFor($user);
|
||||
|
||||
return response()->json([
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
'abilities' => $abilities,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'name' => $user->name,
|
||||
'role' => $user->role,
|
||||
'tenant_id' => $user->tenant_id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$token = $user?->currentAccessToken();
|
||||
|
||||
if ($token) {
|
||||
$token->delete();
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
/** @var Tenant|null $tenant */
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (! $tenant && $user?->tenant_id) {
|
||||
$tenant = Tenant::query()->find($user->tenant_id);
|
||||
}
|
||||
|
||||
$tenantPayload = null;
|
||||
if ($tenant) {
|
||||
$tenantPayload = [
|
||||
'id' => $tenant->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $tenant->name,
|
||||
'slug' => $tenant->slug,
|
||||
'event_credits_balance' => $tenant->event_credits_balance,
|
||||
'features' => $tenant->features,
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'user' => $user ? Arr::only($user->toArray(), [
|
||||
'id',
|
||||
'name',
|
||||
'email',
|
||||
'role',
|
||||
'tenant_id',
|
||||
]) : null,
|
||||
'tenant' => $tenantPayload,
|
||||
'abilities' => $user?->currentAccessToken()?->abilities ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function exchange(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = Auth::guard('web')->user();
|
||||
|
||||
if (! $user) {
|
||||
return response()->json([
|
||||
'error' => 'unauthenticated',
|
||||
'message' => trans('auth.failed'),
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
return response()->json([
|
||||
'error' => 'forbidden',
|
||||
'message' => trans('auth.not_authorized'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
if ($user->email_verified_at === null) {
|
||||
return response()->json([
|
||||
'error' => 'unverified',
|
||||
'message' => trans('auth.unverified'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
[$token, $abilities] = $this->issueTokenFor($user);
|
||||
|
||||
return response()->json([
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
'abilities' => $abilities,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'name' => $user->name,
|
||||
'role' => $user->role,
|
||||
'tenant_id' => $user->tenant_id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveTokenAbilities(User $user): array
|
||||
{
|
||||
$abilities = ['tenant-admin'];
|
||||
|
||||
if ($user->tenant_id) {
|
||||
$abilities[] = 'tenant:'.$user->tenant_id;
|
||||
}
|
||||
|
||||
if ($user->role === 'super_admin') {
|
||||
$abilities[] = 'super-admin';
|
||||
}
|
||||
|
||||
return $abilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: array<int, string>}
|
||||
*/
|
||||
private function issueTokenFor(User $user): array
|
||||
{
|
||||
$user->tokens()->where('name', 'tenant-admin')->delete();
|
||||
|
||||
$abilities = $this->resolveTokenAbilities($user);
|
||||
$token = $user->createToken('tenant-admin', $abilities);
|
||||
|
||||
return [$token->plainTextToken, $abilities];
|
||||
}
|
||||
}
|
||||
112
app/Http/Middleware/EnsureTenantAdminToken.php
Normal file
112
app/Http/Middleware/EnsureTenantAdminToken.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureTenantAdminToken
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): JsonResponse|Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
return $this->unauthorizedResponse('Unauthenticated request.');
|
||||
}
|
||||
|
||||
$accessToken = $user->currentAccessToken();
|
||||
|
||||
if (! $accessToken instanceof PersonalAccessToken) {
|
||||
return $this->unauthorizedResponse('Missing personal access token context.');
|
||||
}
|
||||
|
||||
if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
return $this->forbiddenResponse('Only tenant administrators may access this resource.');
|
||||
}
|
||||
|
||||
if (! $accessToken->can('tenant-admin')) {
|
||||
return $this->forbiddenResponse('Access token does not include the tenant-admin ability.');
|
||||
}
|
||||
|
||||
/** @var Tenant|null $tenant */
|
||||
$tenant = $user->tenant;
|
||||
|
||||
if (! $tenant && $user->role === 'super_admin') {
|
||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||
|
||||
if ($requestedTenantId !== null) {
|
||||
$tenant = Tenant::query()->find($requestedTenantId);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $tenant && $user->role !== 'super_admin') {
|
||||
return $this->forbiddenResponse('Tenant context missing for user.');
|
||||
}
|
||||
|
||||
if ($tenant) {
|
||||
$request->attributes->set('tenant_id', $tenant->id);
|
||||
$request->attributes->set('tenant', $tenant);
|
||||
} elseif ($user->role === 'super_admin') {
|
||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||
if ($requestedTenantId !== null) {
|
||||
$request->attributes->set('tenant_id', $requestedTenantId);
|
||||
}
|
||||
}
|
||||
|
||||
$request->attributes->set('sanctum_token_id', $accessToken->id);
|
||||
|
||||
Auth::shouldUse('sanctum');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function unauthorizedResponse(string $message): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'unauthenticated',
|
||||
'Unauthenticated',
|
||||
$message,
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
private function forbiddenResponse(string $message): JsonResponse
|
||||
{
|
||||
return ApiError::response(
|
||||
'tenant_admin_only',
|
||||
'Forbidden',
|
||||
$message,
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveRequestedTenantId(Request $request): ?int
|
||||
{
|
||||
$routeTenant = $request->route('tenant');
|
||||
if (is_numeric($routeTenant)) {
|
||||
return (int) $routeTenant;
|
||||
}
|
||||
|
||||
$queryTenant = $request->query('tenant_id');
|
||||
if (is_numeric($queryTenant)) {
|
||||
return (int) $queryTenant;
|
||||
}
|
||||
|
||||
$headerTenant = $request->header('X-Tenant-ID');
|
||||
if (is_numeric($headerTenant)) {
|
||||
return (int) $headerTenant;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/Auth/TenantAdminTokenRequest.php
Normal file
36
app/Http/Requests/Auth/TenantAdminTokenRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TenantAdminTokenRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'login' => ['required', 'string'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
public function credentials(): array
|
||||
{
|
||||
$login = $this->string('login')->trim()->value();
|
||||
|
||||
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
|
||||
return ['email' => $login, 'password' => $this->string('password')->value()];
|
||||
}
|
||||
|
||||
return ['username' => $login, 'password' => $this->string('password')->value()];
|
||||
}
|
||||
}
|
||||
125
tests/Feature/Auth/TenantAdminTokenAuthTest.php
Normal file
125
tests/Feature/Auth/TenantAdminTokenAuthTest.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TenantAdminTokenAuthTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_tenant_admin_can_login_and_receive_token(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'role' => 'tenant_admin',
|
||||
'password' => Hash::make('secret-password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson(route('api.v1.tenant-auth.login'), [
|
||||
'login' => $user->email,
|
||||
'password' => 'secret-password',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonStructure([
|
||||
'token',
|
||||
'token_type',
|
||||
'abilities',
|
||||
'user' => ['id', 'email', 'name', 'role', 'tenant_id'],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('personal_access_tokens', 1);
|
||||
}
|
||||
|
||||
public function test_regular_user_cannot_login(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'role' => 'user',
|
||||
'password' => Hash::make('secret-password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson(route('api.v1.tenant-auth.login'), [
|
||||
'login' => $user->email,
|
||||
'password' => 'secret-password',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('login');
|
||||
}
|
||||
|
||||
public function test_unverified_user_cannot_login(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'role' => 'tenant_admin',
|
||||
'password' => Hash::make('secret-password'),
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(route('api.v1.tenant-auth.login'), [
|
||||
'login' => $user->email,
|
||||
'password' => 'secret-password',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors('login');
|
||||
}
|
||||
|
||||
public function test_me_endpoint_returns_user_details(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'role' => 'tenant_admin',
|
||||
'password' => Hash::make('secret-password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$token = $user->createToken('tenant-admin', ['tenant-admin']);
|
||||
|
||||
$response = $this
|
||||
->withToken($token->plainTextToken)
|
||||
->getJson(route('api.v1.tenant-auth.me'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment([
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'role' => 'tenant_admin',
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_logout_revokes_current_token(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'role' => 'tenant_admin',
|
||||
'password' => Hash::make('secret-password'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$token = $user->createToken('tenant-admin', ['tenant-admin']);
|
||||
|
||||
$response = $this
|
||||
->withToken($token->plainTextToken)
|
||||
->postJson(route('api.v1.tenant-auth.logout'));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('personal_access_tokens', [
|
||||
'id' => $token->accessToken->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
68
tests/Feature/Auth/TenantProfileApiTest.php
Normal file
68
tests/Feature/Auth/TenantProfileApiTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TenantProfileApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_me_endpoint_returns_user_and_tenant_payload(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create([
|
||||
'name' => 'Test Tenant GmbH',
|
||||
'slug' => 'test-tenant',
|
||||
'event_credits_balance' => 12,
|
||||
'features' => ['custom_branding' => true],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'role' => 'tenant_admin',
|
||||
'password' => Hash::make('secret-password'),
|
||||
'email' => 'tenant@example.com',
|
||||
'name' => 'Max Mustermann',
|
||||
]);
|
||||
|
||||
$login = $this->postJson('/api/v1/tenant-auth/login', [
|
||||
'login' => 'tenant@example.com',
|
||||
'password' => 'secret-password',
|
||||
]);
|
||||
|
||||
$login->assertOk()->assertJsonStructure(['token', 'token_type', 'abilities']);
|
||||
|
||||
$token = $login->json('token');
|
||||
|
||||
$me = $this->withHeader('Authorization', 'Bearer '.$token)->getJson('/api/v1/tenant-auth/me');
|
||||
|
||||
$me->assertOk();
|
||||
$me->assertJsonFragment([
|
||||
'id' => $user->id,
|
||||
'email' => 'tenant@example.com',
|
||||
'role' => 'tenant_admin',
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
$me->assertJsonFragment([
|
||||
'name' => 'Test Tenant GmbH',
|
||||
'slug' => 'test-tenant',
|
||||
'event_credits_balance' => 12,
|
||||
]);
|
||||
|
||||
$data = $me->json();
|
||||
$this->assertEquals('Max Mustermann', data_get($data, 'user.name'));
|
||||
$this->assertContains('tenant-admin', $data['abilities']);
|
||||
}
|
||||
|
||||
public function test_me_requires_valid_token(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/tenant-auth/me');
|
||||
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user