diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 2272ef7..bcaebd2 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -17,6 +17,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Password; @@ -24,83 +25,71 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Support\ValidatedInput; use Illuminate\View\View; +use Laravel\Sanctum\PersonalAccessToken; use Throwable; class AuthController extends Controller { public function register(RegisterRequest $request): JsonResponse { - $data = $request->validated(); + $user = $this->createRegisteredUser($request); - $avatarPath = null; - if ($request->hasFile('avatar')) { - $avatarPath = $request->file('avatar')->store('avatars', 'public'); + if ($user instanceof JsonResponse) { + return $user; } - $userAttributes = $this->userAttributesFromRequestData($data); - $userAttributes['email'] = strtolower($data['email']); - $userAttributes['password'] = Hash::make($data['password']); - $userAttributes['avatar_url'] = $avatarPath; - - $user = User::create($userAttributes); - - try { - $user->sendEmailVerificationNotification(); - } catch (Throwable $exception) { - report($exception); - - $user->forceDelete(); - - if ($avatarPath !== null) { - Storage::disk('public')->delete($avatarPath); - } - - return response()->json([ - 'message' => __('api.auth.verification_email_failed'), - ], 503); - } - - $token = $user->createToken('api', ['user'])->plainTextToken; + $this->startWebSession($request, $user); return response()->json([ 'message' => __('api.auth.registered_unverified'), 'user' => new UserResource($user), - ], 201)->cookie('token', $token, 60 * 24 * 30); + ], 201); } public function login(LoginRequest $request): JsonResponse { $data = $request->validated(); + $user = $this->getLoginUser($data); - $user = User::where('email', strtolower($data['email']))->first(); - - if (! $user || ! Hash::check($data['password'], $user->password)) { - return response()->json([ - 'message' => __('api.auth.invalid_credentials'), - ], 401); + if ($user instanceof JsonResponse) { + return $user; } - if ($user->isSuspended()) { - return response()->json([ - 'message' => __('api.auth.suspended'), - ], 403); - } - - if (isset($data['locale']) && $user->locale !== $data['locale']) { - $user->forceFill(['locale' => $data['locale']])->save(); - } - - if (! $user->hasVerifiedEmail()) { - return response()->json([ - 'message' => __('api.auth.email_not_verified'), - ], 403); - } - - $token = $user->createToken('api')->plainTextToken; + $this->startWebSession($request, $user); return response()->json([ 'user' => new UserResource($user), - ])->cookie('token', $token, 60 * 24 * 30); + ]); + } + + public function mobileRegister(RegisterRequest $request): JsonResponse + { + $user = $this->createRegisteredUser($request); + + if ($user instanceof JsonResponse) { + return $user; + } + + return response()->json([ + 'message' => __('api.auth.registered_unverified'), + 'token' => $this->createMobileToken($user, $request->validated('device_name')), + 'user' => new UserResource($user), + ], 201); + } + + public function mobileLogin(LoginRequest $request): JsonResponse + { + $data = $request->validated(); + $user = $this->getLoginUser($data); + + if ($user instanceof JsonResponse) { + return $user; + } + + return response()->json([ + 'token' => $this->createMobileToken($user, $data['device_name'] ?? null), + 'user' => new UserResource($user), + ]); } public function verifyEmail(Request $request, string $id, string $hash): JsonResponse|View @@ -224,11 +213,21 @@ class AuthController extends Controller public function logout(): JsonResponse { $request = request(); - $request->user()->currentAccessToken()->delete(); + $accessToken = $request->user()?->currentAccessToken(); + + if ($accessToken instanceof PersonalAccessToken) { + $accessToken->delete(); + } + + if ($request->hasSession()) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } return response()->json([ 'message' => __('api.auth.logout'), - ])->withoutCookie('token'); + ]); } public function me(): JsonResource|JsonResponse @@ -304,9 +303,101 @@ class AuthController extends Controller Storage::disk('public')->delete($storagePaths); } + if ($request->hasSession()) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + return response()->json([ 'message' => __('api.auth.deleted'), - ])->withoutCookie('token'); + ]); + } + + private function createRegisteredUser(RegisterRequest $request): User|JsonResponse + { + $data = $request->validated(); + + $avatarPath = null; + if ($request->hasFile('avatar')) { + $avatarPath = $request->file('avatar')->store('avatars', 'public'); + } + + $userAttributes = $this->userAttributesFromRequestData($data); + $userAttributes['email'] = strtolower($data['email']); + $userAttributes['password'] = Hash::make($data['password']); + $userAttributes['avatar_url'] = $avatarPath; + + $user = User::create($userAttributes); + + try { + $user->sendEmailVerificationNotification(); + } catch (Throwable $exception) { + report($exception); + + $user->forceDelete(); + + if ($avatarPath !== null) { + Storage::disk('public')->delete($avatarPath); + } + + return response()->json([ + 'message' => __('api.auth.verification_email_failed'), + ], 503); + } + + return $user; + } + + /** + * @param array $data + */ + private function getLoginUser(array $data): User|JsonResponse + { + $user = User::where('email', strtolower((string) $data['email']))->first(); + + if (! $user || ! Hash::check((string) $data['password'], $user->password)) { + return response()->json([ + 'message' => __('api.auth.invalid_credentials'), + ], 401); + } + + if ($user->isSuspended()) { + return response()->json([ + 'message' => __('api.auth.suspended'), + ], 403); + } + + if (isset($data['locale']) && $user->locale !== $data['locale']) { + $user->forceFill(['locale' => $data['locale']])->save(); + } + + if (! $user->hasVerifiedEmail()) { + return response()->json([ + 'message' => __('api.auth.email_not_verified'), + ], 403); + } + + return $user; + } + + private function startWebSession(Request $request, User $user): void + { + if (! $request->hasSession()) { + return; + } + + Auth::login($user); + $request->session()->regenerate(); + } + + private function createMobileToken(User $user, mixed $deviceName): string + { + $tokenName = is_string($deviceName) && trim($deviceName) !== '' + ? trim($deviceName) + : 'mobile'; + + return $user->createToken($tokenName)->plainTextToken; } private function isPublicStoragePath(?string $path): bool diff --git a/app/Http/Middleware/AuthenticateWithCookie.php b/app/Http/Middleware/AuthenticateWithCookie.php deleted file mode 100644 index 5ea416e..0000000 --- a/app/Http/Middleware/AuthenticateWithCookie.php +++ /dev/null @@ -1,24 +0,0 @@ -hasCookie('token')) { - $request->headers->set('Authorization', 'Bearer '.$request->cookie('token')); - } - - return $next($request); - } -} diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php index 8a735e7..0180b33 100644 --- a/app/Http/Requests/LoginRequest.php +++ b/app/Http/Requests/LoginRequest.php @@ -23,6 +23,8 @@ class LoginRequest extends FormRequest public function rules(): array { return [ + 'deviceName' => ['sometimes', 'string', 'max:255'], + 'device_name' => ['sometimes', 'string', 'max:255'], 'email' => ['required', 'string'], 'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))], 'password' => ['required', 'string'], @@ -36,6 +38,12 @@ class LoginRequest extends FormRequest 'locale' => $this->normalizeLocale((string) $this->input('locale')), ]); } + + if ($this->has('deviceName') && ! $this->has('device_name')) { + $this->merge([ + 'device_name' => $this->input('deviceName'), + ]); + } } private function normalizeLocale(string $locale): string diff --git a/app/Http/Requests/RegisterRequest.php b/app/Http/Requests/RegisterRequest.php index 70c9aae..02d0e99 100644 --- a/app/Http/Requests/RegisterRequest.php +++ b/app/Http/Requests/RegisterRequest.php @@ -28,6 +28,8 @@ class RegisterRequest extends FormRequest public function rules(): array { return [ + 'deviceName' => ['sometimes', 'string', 'max:255'], + 'device_name' => ['sometimes', 'string', 'max:255'], 'name' => [ 'required', 'string', @@ -72,6 +74,12 @@ class RegisterRequest extends FormRequest 'locale' => $this->normalizeLocale((string) $this->input('locale')), ]); } + + if ($this->has('deviceName') && ! $this->has('device_name')) { + $this->merge([ + 'device_name' => $this->input('deviceName'), + ]); + } } private function normalizeLocale(string $locale): string diff --git a/bootstrap/app.php b/bootstrap/app.php index 6f759c5..8ba1830 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -15,7 +15,7 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - $middleware->prepend(\App\Http\Middleware\AuthenticateWithCookie::class); + $middleware->statefulApi(); $middleware->prepend(\App\Http\Middleware\SetLocale::class); $middleware->alias([ 'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class, diff --git a/config/cors.php b/config/cors.php index bde5827..131c333 100644 --- a/config/cors.php +++ b/config/cors.php @@ -19,7 +19,7 @@ return [ 'allowed_methods' => ['*'], - 'allowed_origins' => ['http://localhost:8081'], + 'allowed_origins' => [], 'allowed_origins_patterns' => [], diff --git a/config/sanctum.php b/config/sanctum.php index adb4e30..7e2e852 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -16,10 +16,10 @@ return [ */ 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( - '%s%s', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + '%s%s%s', + 'localhost,localhost:3000,localhost:8081,localhost:8082,127.0.0.1,127.0.0.1:8000,127.0.0.1:8081,127.0.0.1:8082,::1', Sanctum::currentApplicationUrlWithPort(), - // Sanctum::currentRequestHost(), + Sanctum::currentRequestHost(), ))), /* diff --git a/routes/api.php b/routes/api.php index 7cdd7a0..28e9bde 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,6 +15,8 @@ use Illuminate\Support\Facades\Route; Route::prefix('auth')->group(function (): void { Route::post('register', [AuthController::class, 'register']); Route::post('login', [AuthController::class, 'login']); + Route::post('mobile/register', [AuthController::class, 'mobileRegister']); + Route::post('mobile/login', [AuthController::class, 'mobileLogin']); Route::post('forgot-password', [AuthController::class, 'forgotPassword']) ->middleware('throttle:6,1') ->name('password.email'); diff --git a/tests/Feature/EmailVerificationTest.php b/tests/Feature/EmailVerificationTest.php index ecf98dd..399b7cd 100644 --- a/tests/Feature/EmailVerificationTest.php +++ b/tests/Feature/EmailVerificationTest.php @@ -36,7 +36,8 @@ it('sends a verification email when a user registers', function () { ->assertJsonPath('user.weightGoal', WeightGoal::MAINTAIN_WEIGHT->value) ->assertJsonPath('user.pacePreference', PacePreference::NORMAL->value) ->assertJsonPath('user.sex', UserSex::WOMAN->value) - ->assertJsonPath('user.dateOfBirth', '2003-04-24'); + ->assertJsonPath('user.dateOfBirth', '2003-04-24') + ->assertCookieMissing('token'); $user = User::where('email', 'leon@example.com')->firstOrFail(); @@ -75,6 +76,37 @@ it('returns a temporary error when the verification email cannot be sent during ]); }); +it('registers mobile users with a bearer token', function () { + Notification::fake(); + + $this->postJson('/api/auth/mobile/register', [ + 'name' => 'MobileLeon', + 'email' => 'mobile.leon@example.com', + 'password' => 'Motsdfdepasse123*', + 'deviceName' => 'Bowli Android', + 'locale' => 'fr-FR', + 'physicalActivityLevel' => PhysicalActivityLevel::LIGHTLY_ACTIVE->value, + 'weightGoal' => WeightGoal::MAINTAIN_WEIGHT->value, + 'pacePreference' => PacePreference::NORMAL->value, + 'sex' => UserSex::MAN->value, + 'dateOfBirth' => '2003-04-24', + ]) + ->assertCreated() + ->assertJsonPath('user.email', 'mobile.leon@example.com') + ->assertJsonPath('user.emailVerified', false) + ->assertJsonStructure(['token']) + ->assertCookieMissing('token'); + + $user = User::where('email', 'mobile.leon@example.com')->firstOrFail(); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'name' => 'Bowli Android', + 'tokenable_id' => $user->getKey(), + ]); + + Notification::assertSentTo($user, VerifyEmail::class); +}); + it('uses the custom account verification mailable', function () { $user = User::factory()->unverified()->create([ 'locale' => 'fr', @@ -169,7 +201,7 @@ it('blocks login for unverified users', function () { ]); }); -it('allows login for verified users', function () { +it('allows session login for verified users without issuing a token cookie', function () { $user = User::factory()->create([ 'locale' => 'en', 'password' => 'password', @@ -184,11 +216,54 @@ it('allows login for verified users', function () { ->assertJsonPath('user.email', $user->email) ->assertJsonPath('user.locale', 'fr') ->assertJsonPath('user.emailVerified', true) - ->assertCookie('token'); + ->assertCookieMissing('token'); expect($user->fresh()->locale)->toBe('fr'); }); +it('allows mobile login for verified users with a bearer token', function () { + $user = User::factory()->create([ + 'locale' => 'en', + 'password' => 'password', + ]); + + $this->postJson('/api/auth/mobile/login', [ + 'deviceName' => 'Bowli iOS', + 'email' => $user->email, + 'locale' => 'fr-FR', + 'password' => 'password', + ]) + ->assertOk() + ->assertJsonPath('user.email', $user->email) + ->assertJsonPath('user.locale', 'fr') + ->assertJsonPath('user.emailVerified', true) + ->assertJsonStructure(['token']) + ->assertCookieMissing('token'); + + expect($user->fresh()->locale)->toBe('fr'); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'name' => 'Bowli iOS', + 'tokenable_id' => $user->getKey(), + ]); +}); + +it('logs out mobile users by deleting the current bearer token', function () { + $user = User::factory()->create(); + $token = $user->createToken('Bowli iOS')->plainTextToken; + + $this + ->withToken($token) + ->postJson('/api/auth/logout') + ->assertOk() + ->assertJsonPath('message', __('api.auth.logout')); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'name' => 'Bowli iOS', + 'tokenable_id' => $user->getKey(), + ]); +}); + it('resends a verification email for an unverified user without authentication', function () { Notification::fake();