diff --git a/config/restify.php b/config/restify.php index 139f981fd..0611d2d76 100644 --- a/config/restify.php +++ b/config/restify.php @@ -45,9 +45,41 @@ 'password_reset_url' => env('FRONTEND_APP_URL').'/password/reset?token={token}&email={email}', + /* + |-------------------------------------------------------------------------- + | User Email Verification URL + |-------------------------------------------------------------------------- + | + | This URL is used to redirect users after they click the email verification + | link. The API will validate the verification and redirect to this frontend + | URL with success/failure query parameters. + | + | Available placeholders: + | {id} - User ID + | {emailHash} - SHA1 hash of user's email + | + | Query parameters added by API: + | ?success=true&message=Email verified successfully. + | ?success=false&message=Invalid or expired verification link. + | + */ 'user_verify_url' => env('FRONTEND_APP_URL').'/verify/{id}/{emailHash}', 'user_model' => "\App\Models\User", + + /* + |-------------------------------------------------------------------------- + | Token TTL (Time To Live) + |-------------------------------------------------------------------------- + | + | This value determines the number of minutes that authentication tokens + | will be considered valid. After this time expires, users will need to + | re-authenticate. Set to null for tokens that never expire. + | + | Default: null (never expires) + | + */ + 'token_ttl' => env('RESTIFY_TOKEN_TTL', null), ], /* diff --git a/docs-v3/components/SearchModal.vue b/docs-v3/components/SearchModal.vue index 7cee8267f..63c7565f3 100644 --- a/docs-v3/components/SearchModal.vue +++ b/docs-v3/components/SearchModal.vue @@ -38,9 +38,14 @@ placeholder="Search documentation..." class="flex-1 bg-transparent text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 border-0 focus:ring-0 focus:outline-none text-lg" @input="performSearch" + @keyup="performSearch" @keydown.down.prevent="navigateResults('down')" @keydown.up.prevent="navigateResults('up')" @keydown.enter.prevent="selectResult" + autocomplete="off" + autocorrect="off" + autocapitalize="off" + spellcheck="false" />
- {{ doc.description }} -
diff --git a/src/Commands/PublishAuthCommand.php b/src/Commands/PublishAuthCommand.php index 88dab483d..4f76f852e 100644 --- a/src/Commands/PublishAuthCommand.php +++ b/src/Commands/PublishAuthCommand.php @@ -76,7 +76,7 @@ protected function copyDirectory(string $path, string $stubDirectory, string $fo return true; } - $actionName = Str::before($file->getFilename(), 'Controller'); + $actionName = Str::before($file->getFilename(), 'Controller.stub'); return in_array($actionName, $actions, true) || in_array(Str::lower($actionName), $actions, true); }) @@ -141,7 +141,7 @@ protected function getRouteStubs(): string $routeStubs = ''; foreach ($routes as $action => $routeStub) { - if (! $actions || in_array($action, $actions, true)) { + if (! $actions || in_array($action, $actions, true) || in_array(Str::lower($action), $actions, true)) { $routeStubs .= file_get_contents($stubDirectory.$routeStub); } } diff --git a/src/Commands/SetupCommand.php b/src/Commands/SetupCommand.php index f76e4cc3d..e4f049e5a 100644 --- a/src/Commands/SetupCommand.php +++ b/src/Commands/SetupCommand.php @@ -46,6 +46,10 @@ public function handle() } $this->setAppNamespace(); + + $this->configureUserModel(); + + $this->createAiRoutesFile(); $this->info('Restify setup successfully.'); } @@ -135,4 +139,151 @@ protected function setAppNamespaceOn($file, $namespace) file_get_contents($file) )); } + + /** + * Configure the User model in the restify config. + * + * @return void + */ + protected function configureUserModel() + { + $this->comment('Searching for User models in your application...'); + + $userModels = $this->findUserModels(); + + if (empty($userModels)) { + $this->warn('No User models found in App namespace. Using default \\App\\Models\\User.'); + return; + } + + if (count($userModels) === 1) { + $selectedModel = $userModels[0]; + if ($this->confirm("Found User model: {$selectedModel}. Use this as your authentication model?", true)) { + $this->updateUserModelConfig($selectedModel); + $this->info("Updated restify config to use: {$selectedModel}"); + } + return; + } + + $this->info('Multiple User models found:'); + foreach ($userModels as $index => $model) { + $this->line(" [{$index}] {$model}"); + } + + $choice = $this->ask('Please select the User model to use (enter the number)', '0'); + + if (isset($userModels[$choice])) { + $selectedModel = $userModels[$choice]; + $this->updateUserModelConfig($selectedModel); + $this->info("Updated restify config to use: {$selectedModel}"); + } else { + $this->warn('Invalid selection. Using default \\App\\Models\\User.'); + } + } + + /** + * Find User models in the App namespace. + * + * @return array + */ + protected function findUserModels() + { + $appPath = app_path(); + $namespace = $this->laravel->getNamespace(); + $userModels = []; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($appPath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $relativePath = str_replace($appPath . DIRECTORY_SEPARATOR, '', $file->getPathname()); + $className = str_replace(['/', '.php'], ['\\', ''], $relativePath); + $fqcn = $namespace . $className; + + if ($this->isUserModel($file->getPathname(), $className)) { + $userModels[] = $fqcn; + } + } + } + + return $userModels; + } + + /** + * Check if a PHP file contains a User model. + * + * @param string $filePath + * @param string $className + * @return bool + */ + protected function isUserModel($filePath, $className) + { + if (! str_contains(strtolower($className), 'user')) { + return false; + } + + $content = file_get_contents($filePath); + + return str_contains($content, 'extends Authenticatable') || + str_contains($content, 'use Authenticatable') || + str_contains($content, 'implements AuthenticatableContract') || + str_contains($content, 'HasApiTokens'); + } + + /** + * Update the user_model configuration in restify config. + * + * @param string $userModel + * @return void + */ + protected function updateUserModelConfig($userModel) + { + $configPath = config_path('restify.php'); + + if (! file_exists($configPath)) { + $this->warn('restify.php config file not found.'); + return; + } + + $content = file_get_contents($configPath); + $escapedUserModel = addslashes($userModel); + + $pattern = "/'user_model'\s*=>\s*['\"].*?['\"]/"; + $replacement = "'user_model' => \"{$escapedUserModel}\""; + + $newContent = preg_replace($pattern, $replacement, $content); + + if ($newContent !== $content) { + file_put_contents($configPath, $newContent); + } else { + $this->warn('Could not update user_model configuration.'); + } + } + + /** + * Create the ai.php routes file if it doesn't exist. + * + * @return void + */ + protected function createAiRoutesFile() + { + $routesPath = base_path('routes'); + $aiRoutesFile = $routesPath . '/ai.php'; + + if (file_exists($aiRoutesFile)) { + $this->line('AI routes file already exists.'); + return; + } + + app(Filesystem::class)->ensureDirectoryExists($routesPath); + + $content = "middleware(['auth:sanctum']); // Available at /mcp/restify\n\n// Mcp::local('restify', RestifyServer::class); // Start with ./artisan mcp:start restify\n\n// Example custom servers:\n// Mcp::web('demo', \\App\\Mcp\\Servers\\PublicServer::class); // Available at /mcp/demo\n// Mcp::local('demo', \\App\\Mcp\\Servers\\LocalServer::class); // Start with ./artisan mcp:start demo\n"; + + file_put_contents($aiRoutesFile, $content); + + $this->info('Created routes/ai.php file for MCP server configuration.'); + } } diff --git a/src/Commands/stubs/Auth/VerifyController.stub b/src/Commands/stubs/Auth/VerifyController.stub index 8cbed3573..4469f8733 100644 --- a/src/Commands/stubs/Auth/VerifyController.stub +++ b/src/Commands/stubs/Auth/VerifyController.stub @@ -3,22 +3,49 @@ namespace {{namespace}}; use App\Models\User; -use Binaryk\LaravelRestify\Contracts\Sanctumable; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Events\Verified; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Http\Request; use Illuminate\Routing\Controller; class VerifyController extends Controller { - public function __invoke(int $id, string $hash) + public function __invoke(Request $request, int $id, string $hash) { + $frontendUrl = config('restify.auth.user_verify_url'); + + if (! $request->hasValidSignature()) { + if ($frontendUrl) { + $redirectUrl = str_replace( + ['{id}', '{emailHash}'], + [$id, $hash], + $frontendUrl + ); + + return redirect($redirectUrl . '?success=false&message=' . urlencode('Invalid or expired verification link.')); + } + + throw new AuthorizationException('Invalid or expired verification link.'); + } + /** - * @var User $user + * @var Authenticatable $user */ $user = config('restify.auth.user_model')::query()->findOrFail($id); - if ($user instanceof Sanctumable && ! hash_equals((string) $hash, sha1($user->getEmailForVerification()))) { + if (! hash_equals((string) $hash, sha1($user->getEmailForVerification()))) { + if ($frontendUrl) { + $redirectUrl = str_replace( + ['{id}', '{emailHash}'], + [$user->getKey(), $hash], + $frontendUrl + ); + + return redirect($redirectUrl . '?success=false&message=' . urlencode('Invalid hash')); + } + throw new AuthorizationException('Invalid hash'); } @@ -26,6 +53,18 @@ class VerifyController extends Controller event(new Verified($user)); } - return rest($user); + if ($frontendUrl) { + $redirectUrl = str_replace( + ['{id}', '{emailHash}'], + [$user->getKey(), $hash], + $frontendUrl + ); + + return redirect($redirectUrl . '?success=true&message=' . urlencode('Email verified successfully.')); + } + + return rest($user)->indexMeta([ + 'message' => 'Email verified successfully.', + ]); } } diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/LoginController.php index d506bb0ed..267843170 100644 --- a/src/Http/Controllers/Auth/LoginController.php +++ b/src/Http/Controllers/Auth/LoginController.php @@ -30,8 +30,14 @@ public function __invoke(Request $request) Auth::login($user); + $tokenTtl = config('restify.auth.token_ttl'); + $expiresAt = $tokenTtl ? now()->addMinutes($tokenTtl) : null; + + $token = $user->createToken('login', ['*'], $expiresAt); + return rest($user)->indexMeta([ - 'token' => $user->createToken('login')->plainTextToken, + 'token' => $token->plainTextToken, + 'expires_in' => $tokenTtl ? $tokenTtl * 60 : null, ]); } } diff --git a/src/Http/Controllers/Auth/LogoutController.php b/src/Http/Controllers/Auth/LogoutController.php new file mode 100644 index 000000000..ed545a158 --- /dev/null +++ b/src/Http/Controllers/Auth/LogoutController.php @@ -0,0 +1,25 @@ +currentAccessToken()->delete(); + } + + Auth::logout(); + + return response()->json([ + 'message' => 'Successfully logged out.', + ]); + } +} \ No newline at end of file diff --git a/src/Http/Controllers/Auth/RegisterController.php b/src/Http/Controllers/Auth/RegisterController.php index 365b31976..917dda9a3 100644 --- a/src/Http/Controllers/Auth/RegisterController.php +++ b/src/Http/Controllers/Auth/RegisterController.php @@ -2,6 +2,8 @@ namespace Binaryk\LaravelRestify\Http\Controllers\Auth; +use Binaryk\LaravelRestify\Notifications\VerifyEmail; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Config; @@ -24,8 +26,22 @@ public function __invoke(Request $request) 'password' => Hash::make($request->input('password')), ]); - return rest($user)->indexMeta([ - 'token' => $user->createToken('login')->plainTextToken, - ]); + $tokenTtl = config('restify.auth.token_ttl'); + $expiresAt = $tokenTtl ? now()->addMinutes($tokenTtl) : null; + + $token = $user->createToken('login', ['*'], $expiresAt); + + $meta = [ + 'token' => $token->plainTextToken, + 'expires_in' => $tokenTtl ? $tokenTtl * 60 : null, + ]; + + if ($user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail()) { + $user->notify(new VerifyEmail); + $meta['email_verification_sent'] = true; + $meta['message'] = 'Registration successful. Please check your email to verify your account.'; + } + + return rest($user)->indexMeta($meta); } } diff --git a/src/Http/Controllers/Auth/VerifyController.php b/src/Http/Controllers/Auth/VerifyController.php index 8f657abdb..468526559 100644 --- a/src/Http/Controllers/Auth/VerifyController.php +++ b/src/Http/Controllers/Auth/VerifyController.php @@ -2,23 +2,49 @@ namespace Binaryk\LaravelRestify\Http\Controllers\Auth; -use Binaryk\LaravelRestify\Contracts\Sanctumable; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Events\Verified; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Http\Request; use Illuminate\Routing\Controller; class VerifyController extends Controller { - public function __invoke(int $id, string $hash) + public function __invoke(Request $request, int $id, string $hash) { + $frontendUrl = config('restify.auth.user_verify_url'); + + if (! $request->hasValidSignature()) { + if ($frontendUrl) { + $redirectUrl = str_replace( + ['{id}', '{emailHash}'], + [$id, $hash], + $frontendUrl + ); + + return redirect($redirectUrl . '?success=false&message=' . urlencode('Invalid or expired verification link.')); + } + + throw new AuthorizationException('Invalid or expired verification link.'); + } + /** * @var Authenticatable $user */ $user = config('restify.auth.user_model')::query()->findOrFail($id); - if ($user instanceof Sanctumable && ! hash_equals((string) $hash, sha1($user->getEmailForVerification()))) { + if (! hash_equals((string) $hash, sha1($user->getEmailForVerification()))) { + if ($frontendUrl) { + $redirectUrl = str_replace( + ['{id}', '{emailHash}'], + [$user->getKey(), $hash], + $frontendUrl + ); + + return redirect($redirectUrl . '?success=false&message=' . urlencode('Invalid hash')); + } + throw new AuthorizationException('Invalid hash'); } @@ -26,6 +52,18 @@ public function __invoke(int $id, string $hash) event(new Verified($user)); } - return rest($user); + if ($frontendUrl) { + $redirectUrl = str_replace( + ['{id}', '{emailHash}'], + [$user->getKey(), $hash], + $frontendUrl + ); + + return redirect($redirectUrl . '?success=true&message=' . urlencode('Email verified successfully.')); + } + + return rest($user)->indexMeta([ + 'message' => 'Email verified successfully.', + ]); } } diff --git a/src/Notifications/VerifyEmail.php b/src/Notifications/VerifyEmail.php index 33580135f..01539a0a7 100644 --- a/src/Notifications/VerifyEmail.php +++ b/src/Notifications/VerifyEmail.php @@ -3,6 +3,9 @@ namespace Binaryk\LaravelRestify\Notifications; use Illuminate\Auth\Notifications\VerifyEmail as VerifyEmailLaravel; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\URL; class VerifyEmail extends VerifyEmailLaravel { @@ -14,9 +17,17 @@ class VerifyEmail extends VerifyEmailLaravel */ protected function verificationUrl($notifiable) { - $withToken = str_replace(['{id}'], $notifiable->getKey(), config('restify.auth.user_verify_url')); - $withEmail = str_replace(['{emailHash}'], sha1($notifiable->getEmailForVerification()), $withToken); + if (static::$createUrlCallback) { + return call_user_func(static::$createUrlCallback, $notifiable); + } - return url($withEmail); + return URL::temporarySignedRoute( + 'restify.verify', + Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), + [ + 'id' => $notifiable->getKey(), + 'hash' => sha1($notifiable->getEmailForVerification()), + ] + ); } } diff --git a/src/RestifyApplicationServiceProvider.php b/src/RestifyApplicationServiceProvider.php index beee20597..2d6f9a58c 100644 --- a/src/RestifyApplicationServiceProvider.php +++ b/src/RestifyApplicationServiceProvider.php @@ -6,6 +6,7 @@ use Binaryk\LaravelRestify\Filters\RelatedDto; use Binaryk\LaravelRestify\Http\Controllers\Auth\ForgotPasswordController; use Binaryk\LaravelRestify\Http\Controllers\Auth\LoginController; +use Binaryk\LaravelRestify\Http\Controllers\Auth\LogoutController; use Binaryk\LaravelRestify\Http\Controllers\Auth\RegisterController; use Binaryk\LaravelRestify\Http\Controllers\Auth\ResetPasswordController; use Binaryk\LaravelRestify\Http\Controllers\Auth\VerifyController; @@ -78,7 +79,7 @@ protected function gate(): void protected function authRoutes(): void { - Route::macro('restifyAuth', function ($prefix = '/', array $actions = ['register', 'login', 'verifyEmail', 'forgotPassword', 'resetPassword']) { + Route::macro('restifyAuth', function ($prefix = '/', array $actions = ['register', 'login', 'logout', 'verifyEmail', 'forgotPassword', 'resetPassword']) { Route::group([ 'prefix' => $prefix, 'middleware' => ['api'], @@ -94,6 +95,12 @@ protected function authRoutes(): void ->name('restify.login'); } + if (in_array('logout', $actions, true)) { + Route::post('logout', LogoutController::class) + ->middleware('auth:sanctum') + ->name('restify.logout'); + } + if (in_array('verifyEmail', $actions, true)) { Route::post('verify/{id}/{hash}', VerifyController::class) ->middleware('throttle:6,1')