# 🎮 Controllers — Complete Guide This guide covers all controllers included with Daycry Auth, including the new password reset, force reset, JWT, and user security controllers. ## 📋 Index - [BaseAuthController](#baseauthcontroller) - [LoginController](#logincontroller) - [RegisterController](#registercontroller) - [ActionController](#actioncontroller) - [MagicLinkController](#magiclinkcontroller) - [PasswordResetController](#passwordresetcontroller) - [ForcePasswordResetController](#forcepasswordresetcontroller) - [JwtController](#jwtcontroller) - [UserSecurityController](#usersecuritycontroller) - [Creating Custom Controllers](#creating-custom-controllers) - [Best Practices](#best-practices) --- ## BaseAuthController All auth controllers extend `BaseAuthController`, which provides a set of reusable helper methods. ```php abstract class BaseAuthController extends BaseController implements AuthController { use BaseControllerTrait; // Wires exception handler, attempt handler, request logger use Viewable; // Provides $this->view() // Every subclass must declare its validation rules abstract protected function getValidationRules(): array; } ``` ### Available Helper Methods | Method | Purpose | |--------|---------| | `getTokenArray()` | Returns CSRF token for forms | | `redirectIfLoggedIn()` | Redirects if already authenticated | | `getSessionAuthenticator()` | Returns the session authenticator | | `hasPostAuthAction()` | Checks if a 2FA/activation action is pending | | `redirectToAuthAction()` | Redirects to the action controller | | `extractLoginCredentials()` | Reads login fields from POST | | `shouldRememberUser()` | Checks the "remember me" checkbox | | `validateRequest(array $data, array $rules)` | Runs CI4 validation | | `handleValidationError(?string $route)` | Redirects back with errors | | `handleSuccess(string $url, ?string $message)` | Redirects with success message | | `handleError(string $route, string $error)` | Redirects back with error | | `handleAuthResult(Result $result, string $failureRoute)` | Handles an auth result cleanly | --- ## LoginController Handles traditional email + password login and logout. **Routes** (from `Config/Auth.php`): ``` GET /login → loginView() POST /login → loginAction() GET /logout → logoutAction() ``` ### Extending LoginController ```php redirectIfLoggedIn()) { return $redirect; } // Add custom data to the view $content = $this->view(setting('Auth.views')['login'], [ 'title' => 'Sign In to ' . config('App')->appName, 'socialLogins' => ['google', 'github'], ]); return $this->response->setBody($content); } } ``` --- ## RegisterController Handles user registration. **Routes**: ``` GET /register → registerView() POST /register → registerAction() ``` ### Extending RegisterController ```php ['label' => 'First Name', 'rules' => 'required|max_length[50]'], 'lastname' => ['label' => 'Last Name', 'rules' => 'required|max_length[50]'], ]); } } ``` --- ## ActionController Handles post-authentication actions such as email 2FA, account activation, and TOTP verification. **Routes**: ``` GET /auth/a/show → show() POST /auth/a/handle → handle() POST /auth/a/verify → verify() ``` This controller is called automatically by the library when `$actions['login']` or `$actions['register']` is set in `Config/Auth.php`. You do not typically extend it. --- ## MagicLinkController Handles passwordless login via one-time email links. **Routes**: ``` GET /login/magic-link → loginView() POST /login/magic-link → loginAction() GET /login/verify-magic-link → verify() ``` The base controller is fully functional. Extend only if you need to customise the view or post-login redirect: ```php use Daycry\Auth\Controllers\MagicLinkController as BaseMagicLinkController; class MagicLinkController extends BaseMagicLinkController { // Override only what you need } ``` --- ## PasswordResetController Provides the complete password reset flow for users who have forgotten their password. **Routes** (from `Config/Auth.php`): ``` GET /password-reset → requestView() — Show "Enter your email" form POST /password-reset → requestAction() — Send reset email GET /password-reset/message → messageView() — "Check your inbox" confirmation GET /password-reset/{token} → resetView() — Show "Set new password" form POST /password-reset/{token} → resetAction() — Apply the new password ``` ### How to Enable Register the routes in `app/Config/Routes.php`: ```php $routes->group('', ['namespace' => 'Daycry\Auth\Controllers'], static function ($routes) { $routes->get('password-reset', 'PasswordResetController::requestView', ['as' => 'password-reset']); $routes->post('password-reset', 'PasswordResetController::requestAction'); $routes->get('password-reset/message', 'PasswordResetController::messageView', ['as' => 'password-reset-message']); $routes->get('password-reset/(:any)', 'PasswordResetController::resetView', ['as' => 'password-reset-form']); $routes->post('password-reset/(:any)', 'PasswordResetController::resetAction'); }); ``` ### Configuration ```php // app/Config/Auth.php public int $passwordResetLifetime = HOUR; // How long the token is valid ``` ### Security Design - The `requestAction()` always shows a generic "check your email" message regardless of whether the email exists — this prevents email enumeration. - Tokens are cryptographically secure (20 random bytes). - Tokens are deleted immediately after use. - After a successful reset, `Events::trigger('passwordReset', $user)` fires. ### Adding a Link to Your Login Page ```html Forgot your password? ``` ### Customising the Reset Email Override the view in `Config/Auth.php`: ```php public array $views = [ // ... 'password-reset-email' => '\App\Views\Email\PasswordReset', ]; ``` The view receives the variable `$link` — the full reset URL with the token. ### Listen for Reset Completion ```php // app/Config/Events.php Events::on('passwordReset', static function (object $user): void { // Revoke all active sessions on password change $user->revokeAllAccessTokens(); }); ``` --- ## ForcePasswordResetController When an administrator flags an account for a mandatory password change (e.g., after a security incident), `ForcePasswordResetFilter` intercepts the user and sends them here. **Routes**: ``` GET /force-reset → showView() — Show the form (requires current password) POST /force-reset → resetAction() — Validate and update password ``` ### How to Enable the Filter ```php // app/Config/Filters.php public array $aliases = [ 'force-reset' => \Daycry\Auth\Filters\ForcePasswordResetFilter::class, ]; // app/Config/Routes.php — apply the filter to protected routes $routes->group('dashboard', ['filter' => 'session,force-reset'], static function ($routes) { $routes->get('/', 'Dashboard::index'); }); // Register the reset routes (unfiltered, so user can access them) $routes->get('force-reset', 'Daycry\Auth\Controllers\ForcePasswordResetController::showView', ['as' => 'force-reset']); $routes->post('force-reset', 'Daycry\Auth\Controllers\ForcePasswordResetController::resetAction'); ``` ### Flag a User Programmatically ```php use Daycry\Auth\Models\UserIdentityModel; // Flag one user model(UserIdentityModel::class)->forceMultiplePasswordReset([$userId]); // Flag all users (security breach scenario) model(UserIdentityModel::class)->forceGlobalPasswordReset(); ``` ### What the Form Requires The user must enter: - Their **current password** (verified before changing) - A **new password** and confirmation This prevents someone who has briefly accessed an unlocked computer from changing another user's password. --- ## JwtController Provides a complete stateless JWT authentication API with refresh token rotation. **Routes**: ``` POST /auth/jwt/login → login() — Exchange credentials for access + refresh token POST /auth/jwt/refresh → refresh() — Exchange refresh token for new token pair POST /auth/jwt/logout → logout() — Revoke the refresh token ``` ### Register the Routes ```php $routes->post('auth/jwt/login', 'Daycry\Auth\Controllers\JwtController::login', ['as' => 'jwt-login']); $routes->post('auth/jwt/refresh', 'Daycry\Auth\Controllers\JwtController::refresh', ['as' => 'jwt-refresh']); $routes->post('auth/jwt/logout', 'Daycry\Auth\Controllers\JwtController::logout', ['as' => 'jwt-logout']); ``` ### Configuration ```php // app/Config/Auth.php public int $jwtRefreshLifetime = 30 * DAY; // Refresh token validity ``` ### login() **Request** (`application/x-www-form-urlencoded` or JSON): ``` email=user@example.com password=secret ``` **Response** (200): ```json { "access_token": "eyJ0eXAi...", "refresh_token": "a3f8c2d1...", "user_id": 42, "token_type": "Bearer" } ``` **Error** (401): ```json { "message": "Invalid credentials." } ``` ### refresh() **Request**: ``` user_id=42 refresh_token=a3f8c2d1... ``` **Response** (200): ```json { "access_token": "eyJ0eXAi...", "refresh_token": "newtoken...", "user_id": 42, "token_type": "Bearer" } ``` The **old refresh token is immediately revoked**. Store the new one in your client. ### logout() **Request**: ``` user_id=42 refresh_token=a3f8c2d1... ``` **Response** (200): ```json { "message": "Logged out successfully." } ``` ### Security Notes - Access tokens are short-lived JWTs (configured in `Config/JWT.php`). - Refresh tokens are stored hashed (SHA-256) in `auth_users_identities` with type `jwt_refresh`. - Each refresh token is one-time use — a new pair is issued on every `/refresh` call. - Revoke a refresh token by calling `/logout` or by revoking the identity record directly. --- ## UserSecurityController Provides self-service security management for logged-in users: change password, change email, manage device sessions, and unlink OAuth providers. **All routes require an active session** (`filter: session`). ### Register the Routes ```php // app/Config/Routes.php $routes->group('security', ['filter' => 'session', 'namespace' => 'Daycry\Auth\Controllers'], static function ($routes) { // Change password $routes->get('password', 'UserSecurityController::changePasswordView', ['as' => 'security-password']); $routes->post('password', 'UserSecurityController::changePassword'); // Change email $routes->get('email', 'UserSecurityController::changeEmailView', ['as' => 'security-email']); $routes->post('email', 'UserSecurityController::changeEmail'); $routes->get('email/confirm', 'UserSecurityController::confirmEmailChange', ['as' => 'security-email-confirm']); // Device sessions $routes->get('sessions', 'UserSecurityController::deviceSessionsView', ['as' => 'security-sessions']); $routes->delete('sessions/(:segment)', 'UserSecurityController::terminateDeviceSession/$1'); $routes->delete('sessions/other/all', 'UserSecurityController::terminateOtherDeviceSessions'); // TOTP $routes->get('totp/enable', 'UserSecurityController::totpEnableView', ['as' => 'totp-enable']); $routes->post('totp/enable', 'UserSecurityController::totpEnableAction'); $routes->post('totp/disable', 'UserSecurityController::totpDisableAction', ['as' => 'totp-disable']); // OAuth $routes->post('oauth/unlink/(:segment)', 'UserSecurityController::unlinkOauth/$1', ['as' => 'oauth-unlink']); // Login activity feed (recent attempts) $routes->get('activity', 'UserSecurityController::loginActivity', ['as' => 'security-activity']); }); ``` ### changePassword() Users can change their password by providing their current password + new password. **Form fields**: - `current_password` — verified before changing - `new_password` - `new_password_confirm` — must match `new_password` ### changeEmail() Initiates an email address change. The system sends a confirmation link to the **new** email address. The address is only updated once the link is clicked. **Form fields**: - `new_email` — the new address (must be a valid email not already in use) - `current_password` — required to authorise the change ### confirmEmailChange() Called when the user clicks the confirmation link in their email. Validates the token and updates the email address. ### unlinkOauth() Removes an OAuth provider link from the user's account. Fails gracefully if the user would have no remaining login method. ### loginActivity() Returns a Bootstrap-styled view (`Views/security/login_activity.php`) listing the user's recent login attempts (success + failure). Reads from `auth_logins` via `LoginModel::recentForUser()`. **Query string parameters:** | Param | Default | Max | Description | |-------|---------|-----|-------------| | `limit` | `25` | `100` | Number of entries to display. | **Override the view** via `setting('Auth.views')['security_login_activity']`: ```php // app/Config/Auth.php public array $views = [ // ... 'security_login_activity' => 'App\Views\security\my_activity_feed', ]; ``` The default view exposes these variables: | Variable | Type | Description | |----------|------|-------------| | `$entries` | `list<\Daycry\Auth\Entities\Login>` | Login rows newest-first. | | `$limit` | `int` | The applied limit. | ```html 'post']) ?> ``` --- ## Creating Custom Controllers ### Minimal Custom Controller Any controller that needs auth features can extend `BaseAuthController`: ```php ['label' => 'Name', 'rules' => 'required|max_length[100]'], ]; } public function update(): ResponseInterface { $postData = $this->request->getPost(); if (! $this->validateRequest($postData, $this->getValidationRules())) { return $this->handleValidationError(route_to('account-edit')); } $user = auth()->user(); $user->username = $postData['name']; model(\Daycry\Auth\Models\UserModel::class)->save($user); return $this->handleSuccess(route_to('account'), 'Profile updated successfully.'); } } ``` ### API Controller with JWT + Access Token ```php user(); // Works with any authenticator (jwt, access_token) if (! $user->can('posts.view')) { return $this->failForbidden('You do not have permission to view posts.'); } $posts = model(\App\Models\PostModel::class) ->where('user_id', $user->id) ->findAll(); return $this->respond(['data' => $posts]); } public function create() { $user = auth()->user(); if (! $user->can('posts.create')) { return $this->failForbidden('You cannot create posts.'); } $data = $this->request->getJSON(true); // ... create post return $this->respondCreated(['message' => 'Post created.']); } } ``` --- ## Best Practices ### 1. Always Use BaseAuthController for Auth Logic Avoid duplicating redirect, validation, and error-handling code. `BaseAuthController`'s helper methods handle all of this consistently. ### 2. Validate in getValidationRules(), Not in Action Methods ```php // Good protected function getValidationRules(): array { return ['email' => ['rules' => 'required|valid_email']]; } public function someAction() { if (! $this->validateRequest($this->request->getPost(), $this->getValidationRules())) { return $this->handleValidationError('/form-url'); } // ... } // Avoid public function someAction() { if (! $this->request->getPost('email')) { // Ad-hoc validation return redirect()->back()->with('error', 'Email required'); } } ``` ### 3. Check Permissions Early ```php public function adminAction() { if (! auth()->user()->inGroup('admin')) { return redirect()->to('/')->with('error', 'Unauthorized'); } // ... rest of action } ``` ### 4. Use Events for Side Effects Don't put email sending, logging, or audit trails directly in controllers. Use CI4 Events instead: ```php // In a controller $user->setPassword($newPassword); model(UserModel::class)->save($user); Events::trigger('passwordChanged', $user); // ← clean // In app/Config/Events.php — the side effect lives here Events::on('passwordChanged', fn($user) => /* send notification */); ``` ### 5. Return Correct HTTP Status Codes in APIs ```php return $this->response->setStatusCode(422)->setJSON(['errors' => $errors]); // Validation return $this->response->setStatusCode(401)->setJSON(['message' => 'Unauthenticated']); return $this->response->setStatusCode(403)->setJSON(['message' => 'Forbidden']); ``` --- 🔗 **See also**: - [Filters](04-filters.md) — Protect routes before they reach controllers - [Authorization](06-authorization.md) — Groups and permissions - [Authentication](03-authentication.md) — JWT refresh tokens, password reset flow - [TOTP 2FA](10-totp-2fa.md) — Two-factor authentication - [Device Sessions](11-device-sessions.md) — Session management