diff --git a/examples/Controllers/LoginController.php b/examples/Controllers/LoginController.php new file mode 100644 index 000000000..dc78f5582 --- /dev/null +++ b/examples/Controllers/LoginController.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Shield\Controllers; + +use App\Controllers\BaseController; +use CodeIgniter\HTTP\RedirectResponse; + +class LoginController extends BaseController +{ + /** + * Displays the form the login to the site. + * + * @return RedirectResponse|string + */ + public function loginView() + { + if (auth()->loggedIn()) { + return redirect()->to(config('Auth')->loginRedirect()); + } + + /** @var Session $authenticator */ + $authenticator = auth('session')->getAuthenticator(); + + // If an action has been defined, start it up. + if ($authenticator->hasAction()) { + return redirect()->route('auth-action-show'); + } + + return $this->view(setting('Auth.views')['login']); + } +} diff --git a/examples/Controllers/MagicLinkController.php b/examples/Controllers/MagicLinkController.php new file mode 100644 index 000000000..2a72e1fcc --- /dev/null +++ b/examples/Controllers/MagicLinkController.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Shield\Controllers; + +use App\Controllers\BaseController; +use CodeIgniter\HTTP\RedirectResponse; + +/** + * Handles "Magic Link" logins - an email-based + * no-password login protocol. This works much + * like password reset would, but Shield provides + * this in place of password reset. It can also + * be used on it's own without an email/password + * login strategy. + */ +class MagicLinkController extends BaseController +{ + + /** + * Receives the email from the user, creates the hash + * to a user identity, and sends an email to the given + * email address. + * + * @return RedirectResponse|string + */ + public function loginAction() + { + if (! setting('Auth.allowMagicLinkLogins')) { + return redirect()->route('login')->with('error', lang('Auth.magicLinkDisabled')); + } + + // Validate email format + $rules = $this->getValidationRules(); + if (! $this->validateData($this->request->getPost(), $rules, [], config('Auth')->DBGroup)) { + return redirect()->route('magic-link')->with('errors', $this->validator->getErrors()); + } + + // Check if the user exists + $email = $this->request->getPost('email'); + $user = $this->provider->findByCredentials(['email' => $email]); + + if ($user === null) { + return redirect()->route('magic-link')->with('error', lang('Auth.invalidEmail')); + } + + /** @var UserIdentityModel $identityModel */ + $identityModel = model(UserIdentityModel::class); + + // Delete any previous magic-link identities + $identityModel->deleteIdentitiesByType($user, Session::ID_TYPE_MAGIC_LINK); + + // Generate the code and save it as an identity + helper('text'); + $token = random_string('crypto', 20); + + $identityModel->insert([ + 'user_id' => $user->id, + 'type' => Session::ID_TYPE_MAGIC_LINK, + 'secret' => $token, + 'expires' => Time::now()->addSeconds(setting('Auth.magicLinkLifetime')), + ]); + + /** @var IncomingRequest $request */ + $request = service('request'); + + $ipAddress = $request->getIPAddress(); + $userAgent = (string) $request->getUserAgent(); + $date = Time::now()->toDateTimeString(); + + // Send the user an email with the code + helper('email'); + $email = emailer(['mailType' => 'html']) + ->setFrom(setting('Email.fromEmail'), setting('Email.fromName') ?? ''); + $email->setTo($user->email); + $email->setSubject(lang('Auth.magicLinkSubject')); + $email->setMessage($this->view( + setting('Auth.views')['magic-link-email'], + ['token' => $token, 'ipAddress' => $ipAddress, 'userAgent' => $userAgent, 'date' => $date], + ['debug' => false] + )); + + if ($email->send(false) === false) { + log_message('error', $email->printDebugger(['headers'])); + + return redirect()->route('magic-link')->with('error', lang('Auth.unableSendEmailToUser', [$user->email])); + } + + // Clear the email + $email->clear(); + + return $this->displayMessage(); + } +} diff --git a/examples/Controllers/RegisterController.php b/examples/Controllers/RegisterController.php new file mode 100644 index 000000000..64dacaba5 --- /dev/null +++ b/examples/Controllers/RegisterController.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Shield\Controllers; + +use App\Controllers\BaseController; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\Events\Events; + + +/** + * Class RegisterController + * + * Handles displaying registration form, + * and handling actual registration flow. + */ +class RegisterController extends BaseController +{ + /** + * Displays the registration form. + * + * @return RedirectResponse|string + */ + public function registerView() + { + if (auth()->loggedIn()) { + return redirect()->to(config('Auth')->registerRedirect()); + } + + // Check if registration is allowed + if (! setting('Auth.allowRegistration')) { + return redirect()->back()->withInput() + ->with('error', lang('Auth.registerDisabled')); + } + + /** @var Session $authenticator */ + $authenticator = auth('session')->getAuthenticator(); + + // If an action has been defined, start it up. + if ($authenticator->hasAction()) { + return redirect()->route('auth-action-show'); + } + + return $this->view(setting('Auth.views')['register']); + } + + /** + * Attempts to register the user. + */ + public function registerAction(): RedirectResponse + { + if (auth()->loggedIn()) { + return redirect()->to(config('Auth')->registerRedirect()); + } + + // Check if registration is allowed + if (! setting('Auth.allowRegistration')) { + return redirect()->back()->withInput() + ->with('error', lang('Auth.registerDisabled')); + } + + $users = $this->getUserProvider(); + + // Validate here first, since some things, + // like the password, can only be validated properly here. + $rules = $this->getValidationRules(); + + if (! $this->validateData($this->request->getPost(), $rules, [], config('Auth')->DBGroup)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + + // Save the user + $allowedPostFields = array_keys($rules); + $user = $this->getUserEntity(); + $user->fill($this->request->getPost($allowedPostFields)); + + // Workaround for email only registration/login + if ($user->username === null) { + $user->username = null; + } + + try { + $users->save($user); + } catch (ValidationException $e) { + return redirect()->back()->withInput()->with('errors', $users->errors()); + } + + // To get the complete user object with ID, we need to get from the database + $user = $users->findById($users->getInsertID()); + + // Add to default group + $users->addToDefaultGroup($user); + + Events::trigger('register', $user); + + /** @var Session $authenticator */ + $authenticator = auth('session')->getAuthenticator(); + + $authenticator->startLogin($user); + + // If an action has been defined for register, start it up. + $hasAction = $authenticator->startUpAction('register', $user); + if ($hasAction) { + return redirect()->route('auth-action-show'); + } + + // Set the user active + $user->activate(); + + $authenticator->completeLogin($user); + + // Success! + return redirect()->to(config('Auth')->registerRedirect()) + ->with('message', lang('Auth.registerSuccess')); + } +} diff --git a/src/Commands/Extend.php b/src/Commands/Extend.php new file mode 100644 index 000000000..fb048f01a --- /dev/null +++ b/src/Commands/Extend.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Shield\Commands; +use CodeIgniter\CLI\CLI; +use CodeIgniter\Shield\Commands\Setup\ContentReplacer; + +class Extend extends BaseCommand +{ + /** + * The Command's Name + * + * @var string + */ + protected $name = 'shield:extend'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Extending the Controllers.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'shield:extend'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = []; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '-i' => 'The index of shield controllers to be extending in your app.', + '-f' => 'Force overwrite ALL existing files in destination.', + ]; + + protected $sourcePath; + + protected $distPath = APPPATH; + private ContentReplacer $replacer; + + private const INFO_MESSAGE = " After extending, don't forget to change the route. See https://shield.codeigniter.com/customization/route_config"; + + /** + * Actually execute a command. + * + * @param array $params + */ + public function run(array $params) + { + $this->replacer = new ContentReplacer(); + $this->sourcePath = __DIR__ . '/../../examples/'; + // Get option -i value + $index = CLI::getOption('i'); + + // if no option -i provided show this prompt to user + if($params === [] || array_key_exists('i', $params) === false || $params['i'] === null ) { + $this->write('List of the controller that will be extend:'); + $this->write(); + $this->write(' [1] LoginController'); + $this->write(' [2] MagicLinkController'); + $this->write(' [3] RegisterController'); + $this->write(); + $index = $this->prompt('Please select one of these (1/2/3)'); + } + + switch ((int) $index) { + case 1: + $this->extendingLoginController(); + break; + case 2: + $this->extendingMagicLinkController(); + break; + case 3: + $this->extendingRegisterController(); + break; + + default: + $this->write(); + CLI::error(" Extending canceled: your input not match with any index."); + $this->write(); + break; + } + + return 0; + } + + private function extendingLoginController() + { + $file = 'Controllers/LoginController.php'; + $replaces = [ + 'namespace CodeIgniter\Shield\Controllers' => 'namespace App\Controllers', + 'use App\\Controllers\\BaseController' => 'use CodeIgniter\\Shield\\Controllers\\LoginController as ShieldLoginController', + 'extends BaseController' => 'extends ShieldLoginController', + ]; + + $this->copyAndReplace($file, $replaces); + } + + private function extendingMagicLinkController() + { + $file = 'Controllers/MagicLinkController.php'; + $replaces = [ + 'namespace CodeIgniter\Shield\Controllers' => 'namespace App\Controllers', + 'use App\\Controllers\\BaseController' => 'use CodeIgniter\\Shield\\Controllers\\MagicLinkController as ShieldMagicLinkController', + 'extends BaseController' => 'extends ShieldMagicLinkController', + ]; + + $this->copyAndReplace($file, $replaces); + } + + private function extendingRegisterController() + { + $file = 'Controllers/RegisterController.php'; + $replaces = [ + 'namespace CodeIgniter\Shield\Controllers' => 'namespace App\Controllers', + 'use App\\Controllers\\BaseController' => 'use CodeIgniter\\Shield\\Controllers\\RegisterController as ShieldRegisterController', + 'extends BaseController' => 'extends ShieldRegisterController', + ]; + + $this->copyAndReplace($file, $replaces); + } + + /** + * @param string $file Relative file path like 'Config/Auth.php'. + * @param array $replaces [search => replace] + */ + protected function copyAndReplace(string $file, array $replaces): void + { + $path = "{$this->sourcePath}/{$file}"; + + $content = file_get_contents($path); + + $content = $this->replacer->replace($content, $replaces); + + $this->writeFile($file, $content); + } + + /** + * Write a file, catching any exceptions and showing a + * nicely formatted error. + * + * @param string $file Relative file path like 'Config/Auth.php'. + */ + protected function writeFile(string $file, string $content): void + { + $path = $this->distPath . $file; + $cleanPath = clean_path($path); + + $directory = dirname($path); + + if (! is_dir($directory)) { + mkdir($directory, 0777, true); + } + + if (file_exists($path)) { + $overwrite = (bool) CLI::getOption('f'); + + if ( + ! $overwrite + && $this->prompt(" File '{$cleanPath}' already exists in destination. Overwrite?", ['n', 'y']) === 'n' + ) { + $this->error(" Skipped {$cleanPath}. If you wish to overwrite, please use the '-f' option or reply 'y' to the prompt."); + + return; + } + } + + if (write_file($path, $content)) { + $this->write(); + $this->write(CLI::color(' Created: ', 'green') . $cleanPath); + $this->write(self::INFO_MESSAGE, 'light_green'); + $this->write(); + } else { + $this->error(" Error creating {$cleanPath}."); + } + } +}