diff --git a/backend/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/backend/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 0000000..a33d282 --- /dev/null +++ b/backend/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,38 @@ +authenticate(); + + $request->session()->regenerate(); + + return response()->noContent(); + } + + /** + * Destroy an authenticated session. + */ + public function destroy(Request $request): Response + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + + $request->session()->regenerateToken(); + + return response()->noContent(); + } +} diff --git a/backend/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/backend/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 0000000..a20ba71 --- /dev/null +++ b/backend/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,26 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(RouteServiceProvider::HOME); + } + + $request->user()->sendEmailVerificationNotification(); + + return response()->json(['status' => 'verification-link-sent']); + } +} diff --git a/backend/app/Http/Controllers/Auth/NewPasswordController.php b/backend/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 0000000..16cc2c4 --- /dev/null +++ b/backend/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,53 @@ +validate([ + 'token' => ['required'], + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + // Here we will attempt to reset the user's password. If it is successful we + // will update the password on an actual user model and persist it to the + // database. Otherwise we will parse the error and return the response. + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user) use ($request) { + $user->forceFill([ + 'password' => Hash::make($request->password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + if ($status != Password::PASSWORD_RESET) { + throw ValidationException::withMessages([ + 'email' => [__($status)], + ]); + } + + return response()->json(['status' => __($status)]); + } +} diff --git a/backend/app/Http/Controllers/Auth/PasswordResetLinkController.php b/backend/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 0000000..95cde8e --- /dev/null +++ b/backend/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,39 @@ +validate([ + 'email' => ['required', 'email'], + ]); + + // We will send the password reset link to this user. Once we have attempted + // to send the link, we will examine the response then see the message we + // need to show to the user. Finally, we'll send out a proper response. + $status = Password::sendResetLink( + $request->only('email') + ); + + if ($status != Password::RESET_LINK_SENT) { + throw ValidationException::withMessages([ + 'email' => [__($status)], + ]); + } + + return response()->json(['status' => __($status)]); + } +} diff --git a/backend/app/Http/Controllers/Auth/RegisteredUserController.php b/backend/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 0000000..351e1eb --- /dev/null +++ b/backend/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,41 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:'.User::class], + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + event(new Registered($user)); + + Auth::login($user); + + return response()->noContent(); + } +} diff --git a/backend/app/Http/Controllers/Auth/VerifyEmailController.php b/backend/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 0000000..11f7ddf --- /dev/null +++ b/backend/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,32 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended( + config('app.frontend_url').RouteServiceProvider::HOME.'?verified=1' + ); + } + + if ($request->user()->markEmailAsVerified()) { + event(new Verified($request->user())); + } + + return redirect()->intended( + config('app.frontend_url').RouteServiceProvider::HOME.'?verified=1' + ); + } +} diff --git a/backend/app/Http/Kernel.php b/backend/app/Http/Kernel.php index 1fb53dc..5e9d07d 100644 --- a/backend/app/Http/Kernel.php +++ b/backend/app/Http/Kernel.php @@ -39,7 +39,7 @@ class Kernel extends HttpKernel ], 'api' => [ - // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, + \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', \Illuminate\Routing\Middleware\SubstituteBindings::class, ], @@ -62,6 +62,6 @@ class Kernel extends HttpKernel 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, 'signed' => \App\Http\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, + 'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class, ]; } diff --git a/backend/app/Http/Middleware/EnsureEmailIsVerified.php b/backend/app/Http/Middleware/EnsureEmailIsVerified.php new file mode 100644 index 0000000..f562059 --- /dev/null +++ b/backend/app/Http/Middleware/EnsureEmailIsVerified.php @@ -0,0 +1,27 @@ +user() || + ($request->user() instanceof MustVerifyEmail && + ! $request->user()->hasVerifiedEmail())) { + return response()->json(['message' => 'Your email address is not verified.'], 409); + } + + return $next($request); + } +} diff --git a/backend/app/Http/Requests/Auth/LoginRequest.php b/backend/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..4d3637a --- /dev/null +++ b/backend/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,85 @@ + + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } + + /** + * Attempt to authenticate the request's credentials. + * + * @throws \Illuminate\Validation\ValidationException + */ + public function authenticate(): void + { + $this->ensureIsNotRateLimited(); + + if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + RateLimiter::clear($this->throttleKey()); + } + + /** + * Ensure the login request is not rate limited. + * + * @throws \Illuminate\Validation\ValidationException + */ + public function ensureIsNotRateLimited(): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { + return; + } + + event(new Lockout($this)); + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ]); + } + + /** + * Get the rate limiting throttle key for the request. + */ + public function throttleKey(): string + { + return Str::transliterate(Str::lower($this->input('email')).'|'.$this->ip()); + } +} diff --git a/backend/app/Providers/AuthServiceProvider.php b/backend/app/Providers/AuthServiceProvider.php index 54756cd..60d5c64 100644 --- a/backend/app/Providers/AuthServiceProvider.php +++ b/backend/app/Providers/AuthServiceProvider.php @@ -2,18 +2,18 @@ namespace App\Providers; -// use Illuminate\Support\Facades\Gate; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; class AuthServiceProvider extends ServiceProvider { /** - * The model to policy mappings for the application. + * The policy mappings for the application. * * @var array */ protected $policies = [ - // + // 'App\Models\Model' => 'App\Policies\ModelPolicy', ]; /** @@ -21,6 +21,12 @@ class AuthServiceProvider extends ServiceProvider */ public function boot(): void { + $this->registerPolicies(); + + ResetPassword::createUrlUsing(function (object $notifiable, string $token) { + return config('app.frontend_url')."/password-reset/$token?email={$notifiable->getEmailForPasswordReset()}"; + }); + // } } diff --git a/backend/app/Providers/RouteServiceProvider.php b/backend/app/Providers/RouteServiceProvider.php index 1cf5f15..025e874 100644 --- a/backend/app/Providers/RouteServiceProvider.php +++ b/backend/app/Providers/RouteServiceProvider.php @@ -17,7 +17,7 @@ class RouteServiceProvider extends ServiceProvider * * @var string */ - public const HOME = '/home'; + public const HOME = '/dashboard'; /** * Define your route model bindings, pattern filters, and other route configuration. diff --git a/backend/composer.json b/backend/composer.json index d3af80f..93143ce 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -8,6 +8,7 @@ "php": "^8.1", "doctrine/dbal": "^3.6", "guzzlehttp/guzzle": "^7.2", + "laravel/breeze": "^1.21", "laravel/framework": "^10.10", "laravel/sanctum": "^3.2", "laravel/telescope": "^4.14", diff --git a/backend/composer.lock b/backend/composer.lock index bd953d8..3fcf130 100644 --- a/backend/composer.lock +++ b/backend/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": "01f69d10e6a795f735b06b9498ef24b3", + "content-hash": "ee957195d42c63a6861ba52824e5af26", "packages": [ { "name": "brick/math", @@ -1317,6 +1317,68 @@ ], "time": "2021-10-07T12:57:01+00:00" }, + { + "name": "laravel/breeze", + "version": "v1.21.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/breeze.git", + "reference": "a7e7e2acfb2fd332183aae41c445be7a2329e93e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/breeze/zipball/a7e7e2acfb2fd332183aae41c445be7a2329e93e", + "reference": "a7e7e2acfb2fd332183aae41c445be7a2329e93e", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0", + "illuminate/filesystem": "^10.0", + "illuminate/support": "^10.0", + "illuminate/validation": "^10.0", + "php": "^8.1.0" + }, + "require-dev": { + "orchestra/testbench": "^8.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + }, + "laravel": { + "providers": [ + "Laravel\\Breeze\\BreezeServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Breeze\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Minimal Laravel authentication scaffolding with Blade and Tailwind.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/breeze/issues", + "source": "https://github.com/laravel/breeze" + }, + "time": "2023-05-04T15:02:53+00:00" + }, { "name": "laravel/framework", "version": "v10.13.1", diff --git a/backend/config/app.php b/backend/config/app.php index 841e18e..02e46d5 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -57,6 +57,8 @@ return [ 'url' => env('APP_URL', 'http://localhost'), + 'frontend_url' => env('FRONTEND_URL', 'http://localhost:3000'), + 'asset_url' => env('ASSET_URL'), /* diff --git a/backend/config/cors.php b/backend/config/cors.php index 8a39e6d..f2bf88b 100644 --- a/backend/config/cors.php +++ b/backend/config/cors.php @@ -15,11 +15,11 @@ return [ | */ - 'paths' => ['api/*', 'sanctum/csrf-cookie'], + 'paths' => ['*'], 'allowed_methods' => ['*'], - 'allowed_origins' => ['*'], + 'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')], 'allowed_origins_patterns' => [], @@ -29,6 +29,6 @@ return [ 'max_age' => 0, - 'supports_credentials' => false, + 'supports_credentials' => true, ]; diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php index 529cfdc..dc83da7 100644 --- a/backend/config/sanctum.php +++ b/backend/config/sanctum.php @@ -1,7 +1,5 @@ explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( - '%s%s', + '%s%s%s', 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', - Sanctum::currentApplicationUrlWithPort() + env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : '', + env('FRONTEND_URL') ? ','.parse_url(env('FRONTEND_URL'), PHP_URL_HOST) : '' ))), /* @@ -41,8 +40,8 @@ return [ |-------------------------------------------------------------------------- | | This value controls the number of minutes until an issued token will be - | considered expired. If this value is null, personal access tokens do - | not expire. This won't tweak the lifetime of first-party sessions. + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. | */ diff --git a/backend/package.json b/backend/package.json deleted file mode 100644 index e543e0d..0000000 --- a/backend/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build" - }, - "devDependencies": { - "axios": "^1.1.2", - "laravel-vite-plugin": "^0.7.5", - "vite": "^4.0.0" - } -} diff --git a/backend/resources/css/app.css b/backend/resources/css/app.css deleted file mode 100644 index e69de29..0000000 diff --git a/backend/resources/js/app.js b/backend/resources/js/app.js deleted file mode 100644 index e59d6a0..0000000 --- a/backend/resources/js/app.js +++ /dev/null @@ -1 +0,0 @@ -import './bootstrap'; diff --git a/backend/resources/js/bootstrap.js b/backend/resources/js/bootstrap.js deleted file mode 100644 index 846d350..0000000 --- a/backend/resources/js/bootstrap.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * We'll load the axios HTTP library which allows us to easily issue requests - * to our Laravel back-end. This library automatically handles sending the - * CSRF token as a header based on the value of the "XSRF" token cookie. - */ - -import axios from 'axios'; -window.axios = axios; - -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; - -/** - * Echo exposes an expressive API for subscribing to channels and listening - * for events that are broadcast by Laravel. Echo and event broadcasting - * allows your team to easily build robust real-time web applications. - */ - -// import Echo from 'laravel-echo'; - -// import Pusher from 'pusher-js'; -// window.Pusher = Pusher; - -// window.Echo = new Echo({ -// broadcaster: 'pusher', -// key: import.meta.env.VITE_PUSHER_APP_KEY, -// cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER ?? 'mt1', -// wsHost: import.meta.env.VITE_PUSHER_HOST ? import.meta.env.VITE_PUSHER_HOST : `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`, -// wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80, -// wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443, -// forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https', -// enabledTransports: ['ws', 'wss'], -// }); diff --git a/backend/resources/views/.gitkeep b/backend/resources/views/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/resources/views/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/resources/views/welcome.blade.php b/backend/resources/views/welcome.blade.php deleted file mode 100644 index 638ec96..0000000 --- a/backend/resources/views/welcome.blade.php +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - - Laravel - - - - - - - - - -
- @if (Route::has('login')) -
- @auth - Home - @else - Log in - - @if (Route::has('register')) - Register - @endif - @endauth -
- @endif - - -
- - diff --git a/backend/routes/api.php b/backend/routes/api.php index 889937e..d072e04 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -9,11 +9,11 @@ use Illuminate\Support\Facades\Route; |-------------------------------------------------------------------------- | | Here is where you can register API routes for your application. These -| routes are loaded by the RouteServiceProvider and all of them will -| be assigned to the "api" middleware group. Make something great! +| routes are loaded by the RouteServiceProvider within a group which +| is assigned the "api" middleware group. Enjoy building your API! | */ -Route::middleware('auth:sanctum')->get('/user', function (Request $request) { +Route::middleware(['auth:sanctum'])->get('/user', function (Request $request) { return $request->user(); }); diff --git a/backend/routes/auth.php b/backend/routes/auth.php new file mode 100644 index 0000000..ae6cc20 --- /dev/null +++ b/backend/routes/auth.php @@ -0,0 +1,37 @@ +middleware('guest') + ->name('register'); + +Route::post('/login', [AuthenticatedSessionController::class, 'store']) + ->middleware('guest') + ->name('login'); + +Route::post('/forgot-password', [PasswordResetLinkController::class, 'store']) + ->middleware('guest') + ->name('password.email'); + +Route::post('/reset-password', [NewPasswordController::class, 'store']) + ->middleware('guest') + ->name('password.store'); + +Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['auth', 'signed', 'throttle:6,1']) + ->name('verification.verify'); + +Route::post('/email/verification-notification', [EmailVerificationNotificationController::class, 'store']) + ->middleware(['auth', 'throttle:6,1']) + ->name('verification.send'); + +Route::post('/logout', [AuthenticatedSessionController::class, 'destroy']) + ->middleware('auth') + ->name('logout'); diff --git a/backend/routes/web.php b/backend/routes/web.php index 05b8696..9ab7faf 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -1,6 +1,5 @@ app()->version()]; }); -Route::prefix('api')->group(function () { - Route::resources([ - '/posts' => PostController::class, - ]); -}); +require __DIR__.'/auth.php'; diff --git a/backend/tests/Feature/Auth/AuthenticationTest.php b/backend/tests/Feature/Auth/AuthenticationTest.php new file mode 100644 index 0000000..9042caf --- /dev/null +++ b/backend/tests/Feature/Auth/AuthenticationTest.php @@ -0,0 +1,37 @@ +create(); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertNoContent(); + } + + public function test_users_can_not_authenticate_with_invalid_password(): void + { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $this->assertGuest(); + } +} diff --git a/backend/tests/Feature/Auth/EmailVerificationTest.php b/backend/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..750e616 --- /dev/null +++ b/backend/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,54 @@ +create([ + 'email_verified_at' => null, + ]); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + $response->assertRedirect(config('app.frontend_url').RouteServiceProvider::HOME.'?verified=1'); + } + + public function test_email_is_not_verified_with_invalid_hash(): void + { + $user = User::factory()->create([ + 'email_verified_at' => null, + ]); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->actingAs($user)->get($verificationUrl); + + $this->assertFalse($user->fresh()->hasVerifiedEmail()); + } +} diff --git a/backend/tests/Feature/Auth/PasswordResetTest.php b/backend/tests/Feature/Auth/PasswordResetTest.php new file mode 100644 index 0000000..de9af97 --- /dev/null +++ b/backend/tests/Feature/Auth/PasswordResetTest.php @@ -0,0 +1,47 @@ +create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_password_can_be_reset_with_valid_token(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function (object $notification) use ($user) { + $response = $this->post('/reset-password', [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response->assertSessionHasNoErrors(); + + return true; + }); + } +} diff --git a/backend/tests/Feature/Auth/RegistrationTest.php b/backend/tests/Feature/Auth/RegistrationTest.php new file mode 100644 index 0000000..b48e150 --- /dev/null +++ b/backend/tests/Feature/Auth/RegistrationTest.php @@ -0,0 +1,24 @@ +post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertNoContent(); + } +} diff --git a/backend/vite.config.js b/backend/vite.config.js deleted file mode 100644 index 421b569..0000000 --- a/backend/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vite'; -import laravel from 'laravel-vite-plugin'; - -export default defineConfig({ - plugins: [ - laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], - refresh: true, - }), - ], -});