diff --git a/.env.example b/.env.example index 1ee24a5..40d9bb1 100644 --- a/.env.example +++ b/.env.example @@ -50,3 +50,18 @@ TURNSTILE_SECRET_KEY= # EXPORT_MAX_ROWS_XLSX=250000 # EXPORT_MAX_ROWS_PDF=2000 # EXPORT_TIME_LIMIT=300 + +# --- Accounts & e-mail (all optional; README "Accounts & e-mail") --- +# mail | smtp | sendmail | log (development only: writes mails to application/logs/mail-DATE.log) +# MAIL_PROTOCOL=mail +# SMTP_HOST= +# SMTP_PORT=587 +# SMTP_USER= +# SMTP_PASS= +# SMTP_CRYPTO=tls +# MAIL_FROM_ADDRESS= +# MAIL_FROM_NAME= +# Sign in with Google (Settings -> Accounts & e-mail wins over these defaults) +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= + diff --git a/README.md b/README.md index a83506f..c7f066a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ If you've ever done a fresh CodeIgniter 3 install on PHP 8.2+ and immediately se - [Working with HMVC modules](#working-with-hmvc-modules) - [Template & theming](#template--theming) - [Authentication](#authentication) +- [Accounts & e-mail (verification, password reset, Google)](#accounts--e-mail-verification-password-reset-google) - [Roles & permissions](#roles--permissions) - [Login security & audit log](#login-security--audit-log) - [Exports](#exports) @@ -311,6 +312,28 @@ has_role('admin'); // bool The `users` table comes from `application/migrations/20260813120000_create_users_table.php` — run `php index.php console migrate` to create it. This is intentionally minimal (no password reset, no email verification, no OAuth) — extend `application/modules/auth/controllers/Auth.php` for anything beyond that. +## Accounts & e-mail (verification, password reset, Google) + +Three optional features, all **off by default** and switched on under **Settings → Accounts & e-mail**. Run `php index.php console migrate` first (it adds `users.email_verified_at`, `users.google_sub` and the `user_tokens` table; every existing user counts as confirmed). + +**E-mail.** Configure delivery in `.env` (nothing secret lives in the config file): + +| `.env` | | +|---|---| +| `MAIL_PROTOCOL` | `mail` (PHP's `mail()`, default), `smtp`, `sendmail`, or `log` (development only: writes each message to `application/logs/mail-DATE.log` instead of sending, so you can click the links; refused in production) | +| `SMTP_HOST`, `SMTP_PORT` (587), `SMTP_USER`, `SMTP_PASS`, `SMTP_CRYPTO` (`tls`/`ssl`) | for `smtp` | +| `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | defaults for the sender (Settings overrides them) | + +Check it with `php index.php console mailtest you@example.com`. A failed send is logged and never breaks or reveals anything on the page. + +**Require e-mail verification.** New accounts are not logged in; they get a link (valid 3 days) and must confirm before they can log in. Someone who knows the password but has not confirmed is told so, and can ask for a new link; a wrong password gets the usual message, so nothing leaks. The link opens a page with a button (a mail scanner that "visits" links cannot use it up) and does not log anybody in. With the setting off, new accounts are confirmed from the start and logged in as before. + +**Forgot password.** A link on the login page e-mails a reset link valid for **1 hour**. The answer is identical whether or not the address has an account; asking is limited to 5 per hour per IP address and 3 per hour per address; Turnstile applies if it is on (Settings → Security). The new password must be 8+ characters; the link works once, a newer request cancels an older link, and the person logs in afterwards (no automatic login). Pages whose address holds a token are `no-store` with `Referrer-Policy: no-referrer`. + +**Sign in with Google.** Create an OAuth client of type *Web application* in the Google Cloud console, add the redirect URI `https://your-site/google/callback` exactly, and paste the client ID and secret into Settings (or `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` in `.env`). The flow is OpenID Connect's authorization-code flow with `state`, `nonce` and PKCE; the sign-in session is single use and expires after 10 minutes. Only addresses Google marks as verified are accepted. Who gets in: an account already linked to that Google id; else the account with the same address, which is then linked (if that account never confirmed its address, its password is also replaced by a random one, so whoever registered it first loses their way in); else a new account with the default role. Accounts blocked by the login-security module cannot use Google to get around the block. + +How the links are kept safe: tokens are 32 random bytes (64 hex characters), only their SHA-256 hash is stored, they are single use (an atomic `UPDATE ... WHERE used_at IS NULL`), and `console prune` deletes old ones. Known limit: when the mail server is slow, the response time of "forgot password" can hint whether an address has an account; use a local mail relay or a queue if that matters to you. + ## Roles & permissions Each user has **one role**; each role holds a set of **permissions**. Both are managed in the admin area (sidebar → **Roles** and **Users**), not in code. diff --git a/application/config/app_settings.php b/application/config/app_settings.php index 0c757a8..4821a68 100644 --- a/application/config/app_settings.php +++ b/application/config/app_settings.php @@ -87,6 +87,55 @@ ), ), + 'accounts' => array( + 'label' => 'Accounts & e-mail', + 'icon' => 'bi-envelope-check', + 'fields' => array( + 'email_verification_enabled' => array( + 'label' => 'Require e-mail verification', + 'type' => 'switch', + 'default' => FALSE, + 'help' => 'New accounts must click the link we e-mail before they can log in. Needs working e-mail (see the README, "Accounts & e-mail"); test it with: php index.php console mailtest you@example.com. Accounts that already exist count as verified.', + ), + 'password_reset_enabled' => array( + 'label' => 'Allow "Forgot password?"', + 'type' => 'switch', + 'default' => FALSE, + 'help' => 'Adds a link on the login page that e-mails a reset link (valid for 1 hour). Needs working e-mail.', + ), + 'email_from_name' => array( + 'label' => 'Sender name', + 'type' => 'text', + 'default' => '', + 'help' => 'Leave empty to use the application name.', + ), + 'email_from_address' => array( + 'label' => 'Sender address', + 'type' => 'text', + 'default' => '', + 'help' => 'Leave empty to use MAIL_FROM_ADDRESS from .env, or no-reply@your-host.', + ), + 'google_enabled' => array( + 'label' => 'Sign in with Google', + 'type' => 'switch', + 'default' => FALSE, + 'requires' => array('google_client_id', 'google_client_secret'), + 'help' => 'Shows a "Continue with Google" button on the login and register pages. Create an OAuth client (type "Web application") in the Google Cloud console and add this exact redirect URI: your-site/google/callback (for example https://example.com/google/callback). Only addresses Google has verified are accepted.', + ), + 'google_client_id' => array( + 'label' => 'Google client ID', + 'type' => 'text', + 'default' => getenv('GOOGLE_CLIENT_ID') ?: '', + ), + 'google_client_secret' => array( + 'label' => 'Google client secret', + 'type' => 'password', + 'default' => getenv('GOOGLE_CLIENT_SECRET') ?: '', + 'help' => 'Write-only: leave blank to keep the current one. It can also come from GOOGLE_CLIENT_SECRET in .env.', + ), + ), + ), + 'appearance' => array( 'label' => 'Appearance', 'icon' => 'bi-palette', @@ -187,6 +236,11 @@ 'type' => 'switch', 'default' => TRUE, ), + 'turnstile_on_forgot' => array( + 'label' => 'Require it on the "Forgot password?" form', + 'type' => 'switch', + 'default' => TRUE, + ), 'turnstile_theme' => array( 'label' => 'Widget theme', 'type' => 'select', diff --git a/application/config/email.php b/application/config/email.php new file mode 100644 index 0000000..d27cb46 --- /dev/null +++ b/application/config/email.php @@ -0,0 +1,29 @@ + Accounts & e-mail +| +| `php index.php console mailtest you@example.com` sends a test message. +*/ +$protocol = strtolower((string) (getenv('MAIL_PROTOCOL') ?: 'mail')); + +$config['protocol'] = in_array($protocol, ['smtp', 'sendmail'], true) ? $protocol : 'mail'; +$config['mailtype'] = 'text'; +$config['charset'] = 'utf-8'; +$config['wordwrap'] = true; +$config['newline'] = "\r\n"; +$config['crlf'] = "\r\n"; + +$config['smtp_host'] = (string) (getenv('SMTP_HOST') ?: ''); +$config['smtp_port'] = (int) (getenv('SMTP_PORT') ?: 587); +$config['smtp_user'] = (string) (getenv('SMTP_USER') ?: ''); +$config['smtp_pass'] = (string) (getenv('SMTP_PASS') ?: ''); +$config['smtp_crypto'] = in_array((string) getenv('SMTP_CRYPTO'), ['tls', 'ssl'], true) ? (string) getenv('SMTP_CRYPTO') : ''; +$config['smtp_timeout'] = 10; diff --git a/application/config/routes.php b/application/config/routes.php index 2d4b0f3..23f861d 100644 --- a/application/config/routes.php +++ b/application/config/routes.php @@ -60,6 +60,12 @@ $route['login'] = 'auth/login'; $route['register'] = 'auth/register'; $route['logout'] = 'auth/logout'; +$route['forgot-password'] = 'auth/account/forgot'; +$route['reset-password/([a-f0-9]{64})'] = 'auth/account/reset/$1'; +$route['verify-email/([a-f0-9]{64})'] = 'auth/account/verify/$1'; +$route['resend-verification'] = 'auth/account/resend'; +$route['google/login'] = 'auth/google_auth/login'; +$route['google/callback'] = 'auth/google_auth/callback'; // Admin settings (application/modules/settings). The controller is called // Admin_settings because a class named Settings would clash with the diff --git a/application/controllers/Console.php b/application/controllers/Console.php index 14067ad..bc27d8f 100644 --- a/application/controllers/Console.php +++ b/application/controllers/Console.php @@ -19,6 +19,7 @@ * @property Loginguard $loginguard * @property Audit $audit * @property Settings $settings + * @property Mailer $mailer * @property CI_DB_query_builder $db */ class Console extends CI_Controller @@ -68,7 +69,7 @@ public function prune() { $this->load->database(); $this->load->library('settings'); - $removed = ['audit_logs' => 0, 'login_locks' => 0]; + $removed = ['audit_logs' => 0, 'login_locks' => 0, 'user_tokens' => 0]; $days = (int) $this->settings->get('audit_retention_days', 0); if ($days > 0 && $this->db->table_exists('audit_logs')) { @@ -84,6 +85,13 @@ public function prune() }); } + if ($this->db->table_exists('user_tokens')) { + // Links that expired, or were used, more than a week ago are of no use to anybody. + $removed['user_tokens'] = $this->delete_in_batches('user_tokens', function ($db) { + $db->where('expires_at <', date('Y-m-d H:i:s', time() - 7 * 86400)); + }); + } + foreach ($removed as $table => $n) { echo str_pad($table, 12).' '.$n.' row(s) removed'.PHP_EOL; } @@ -92,6 +100,34 @@ public function prune() } } + /** + * Sends a test e-mail with the mail settings from .env, to see that verification and + * password-reset mails will arrive: + * + * php index.php console mailtest you@example.com + */ + public function mailtest($to = null) + { + $this->load->library('mailer'); + + if ($to === null || filter_var($to, FILTER_VALIDATE_EMAIL) === false) { + echo 'Usage: php index.php console mailtest you@example.com'.PHP_EOL; + + return; + } + + echo 'Transport: '.$this->mailer->transport().', from '.$this->mailer->from_name().' <'.$this->mailer->from_address().'>'.PHP_EOL; + + if ($this->mailer->send($to, 'Test message', "If you can read this, the mail settings work.\n")) { + echo $this->mailer->transport() === 'log' ? 'Written to application/logs/mail-'.date('Y-m-d').'.log (nothing was sent).' : 'Sent to '.$to.'.'; + echo PHP_EOL; + + return; + } + + echo 'Could not send. Check MAIL_PROTOCOL / SMTP_* in .env and application/logs for the reason.'.PHP_EOL; + } + /** * Deletes the rows the filter matches, 2,000 at a time (selected by id first, which works on every driver). */ diff --git a/application/helpers/auth_helper.php b/application/helpers/auth_helper.php index 702c8e2..d2d5e64 100644 --- a/application/helpers/auth_helper.php +++ b/application/helpers/auth_helper.php @@ -130,3 +130,41 @@ function require_permission($permission) } } } + +if (!function_exists('google_login_enabled')) { + /** + * Whether the "Continue with Google" button should be shown: the switch is on + * and both client keys are saved (Settings -> Accounts & e-mail). + */ + function google_login_enabled() + { + return (bool) app_setting('google_enabled') + && trim((string) app_setting('google_client_id')) !== '' + && trim((string) app_setting('google_client_secret')) !== ''; + } +} + +if (!function_exists('google_login_button')) { + /** + * The button (and the "or" line above it) for the login and register pages. A plain + * link: the sign-in starts on the server, so no script is needed (and the strict + * Content-Security-Policy stays as it is). + */ + function google_login_button($label = 'Continue with Google') + { + if (!google_login_enabled()) { + return ''; + } + + return '' + .'' + .''.htmlspecialchars($label, ENT_QUOTES, 'UTF-8').''; + } +} + +if (!function_exists('password_reset_enabled')) { + function password_reset_enabled() + { + return (bool) app_setting('password_reset_enabled'); + } +} diff --git a/application/libraries/Accountlinks.php b/application/libraries/Accountlinks.php new file mode 100644 index 0000000..e658f1a --- /dev/null +++ b/application/libraries/Accountlinks.php @@ -0,0 +1,66 @@ +accountlinks->send_verification($user); // false when the mail could not be sent + * + * The e-mail never contains anything but the link and fixed text. + * + * @property Usertokens $usertokens + * @property Mailer $mailer + * @property Settings $settings + */ +class Accountlinks +{ + protected $CI; + + public function __construct() + { + $this->CI = &get_instance(); + $this->CI->load->library(['usertokens', 'mailer']); + } + + /** + * @param object $user row with id, name, email + */ + public function send_verification($user) + { + $token = $this->CI->usertokens->issue($user->id, Tokens::VERIFY_EMAIL); + + return $this->CI->mailer->send($user->email, 'Confirm your email address', $this->body( + $user, + "Confirm your email address to finish setting up your account (the link works for 3 days):\n".site_url('verify-email/'.$token) + ."\n\nIf you did not create an account, ignore this message." + )); + } + + /** + * @param object $user row with id, name, email + */ + public function send_reset($user) + { + $token = $this->CI->usertokens->issue($user->id, Tokens::RESET_PASSWORD); + + return $this->CI->mailer->send($user->email, 'Reset your password', $this->body( + $user, + "Someone asked to reset the password of your account.\n\nChoose a new password here (the link works for 1 hour):\n".site_url('reset-password/'.$token) + ."\n\nIf that was not you, ignore this message: your password stays as it is." + )); + } + + /** + * @param object $user + */ + protected function body($user, $text) + { + return 'Hello '.preg_replace('/[\r\n]+/', ' ', (string) $user->name).",\n\n".$text."\n\n-- ".$this->CI->settings->get('app_name')."\n"; + } +} diff --git a/application/libraries/Installer.php b/application/libraries/Installer.php index a6180f1..a9ee809 100644 --- a/application/libraries/Installer.php +++ b/application/libraries/Installer.php @@ -205,6 +205,7 @@ public function run(array $v) 'email' => $v['admin_email'], 'password' => password_hash($v['admin_password'], PASSWORD_DEFAULT), 'role' => 'super_admin', + 'email_verified_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); diff --git a/application/libraries/Mailer.php b/application/libraries/Mailer.php new file mode 100644 index 0000000..f02413e --- /dev/null +++ b/application/libraries/Mailer.php @@ -0,0 +1,133 @@ +load->library('mailer'); + * $ok = $this->mailer->send('ann@example.com', 'Subject', "Plain text body"); + * + * Transport comes from .env (see application/config/email.php). In development + * MAIL_PROTOCOL=log writes each message to application/logs/mail-DATE.log so + * links can be followed without a mail server; that mode is refused in production, + * because those files would contain working links. + * + * send() never throws and never echoes: it returns false and logs the reason, so a + * mail outage cannot break (or reveal anything through) the page that asked. + * + * @property CI_Email $email + */ +class Mailer +{ + protected $CI; + + public function __construct() + { + $this->CI = &get_instance(); + } + + /** + * @return string mail | smtp | sendmail | log + */ + public function transport() + { + $protocol = strtolower((string) (getenv('MAIL_PROTOCOL') ?: 'mail')); + + return in_array($protocol, ['mail', 'smtp', 'sendmail', 'log'], true) ? $protocol : 'mail'; + } + + /** + * Whether a message could plausibly be delivered (SMTP needs a host; log is development only). + */ + public function configured() + { + switch ($this->transport()) { + case 'smtp': + return (string) getenv('SMTP_HOST') !== ''; + case 'log': + return ENVIRONMENT !== 'production'; + default: + return true; + } + } + + public function from_address() + { + $address = trim((string) $this->CI->settings->get('email_from_address')); + if ($address === '') { + $address = trim((string) getenv('MAIL_FROM_ADDRESS')); + } + if ($address === '' || filter_var($address, FILTER_VALIDATE_EMAIL) === false) { + $host = preg_replace('/:\d+$/', '', (string) ($_SERVER['HTTP_HOST'] ?? 'localhost')); + $address = 'no-reply@'.(preg_match('/^[A-Za-z0-9.-]+$/', $host) ? $host : 'localhost'); + } + + return $address; + } + + public function from_name() + { + $name = trim((string) $this->CI->settings->get('email_from_name')); + if ($name === '') { + $name = trim((string) getenv('MAIL_FROM_NAME')); + } + + return $name !== '' ? $name : (string) $this->CI->settings->get('app_name'); + } + + /** + * @param string $to one validated address + * @param string $subject a fixed subject (no user input) + * @param string $body plain text + */ + public function send($to, $subject, $body) + { + $to = trim((string) $to); + if (filter_var($to, FILTER_VALIDATE_EMAIL) === false || preg_match('/[\r\n]/', $to.$subject)) { + log_message('error', 'Mailer: refused an unusable recipient or subject.'); + + return false; + } + + if (!$this->configured()) { + log_message('error', 'Mailer: the "'.$this->transport().'" transport is not usable here (set MAIL_PROTOCOL and SMTP_HOST in .env).'); + + return false; + } + + try { + if ($this->transport() === 'log') { + return $this->write_log($to, $subject, $body); + } + + $this->CI->load->library('email'); + $this->CI->email->clear(); + $this->CI->email->from($this->from_address(), $this->from_name()); + $this->CI->email->to($to); + $this->CI->email->subject($subject); + $this->CI->email->message($body); + + if (!$this->CI->email->send(false)) { + log_message('error', 'Mailer: sending failed ('.$this->transport().'): '.trim(strip_tags($this->CI->email->print_debugger(['headers'])))); + + return false; + } + + return true; + } catch (\Throwable $e) { + log_message('error', 'Mailer: '.get_class($e).': '.$e->getMessage()); + + return false; + } + } + + protected function write_log($to, $subject, $body) + { + $file = APPPATH.'logs/mail-'.date('Y-m-d').'.log'; + $entry = '=== '.date('c').PHP_EOL.'To: '.$to.PHP_EOL.'From: '.$this->from_name().' <'.$this->from_address().'>'.PHP_EOL + .'Subject: '.$subject.PHP_EOL.PHP_EOL.$body.PHP_EOL.PHP_EOL; + + return @file_put_contents($file, $entry, FILE_APPEND | LOCK_EX) !== false; + } +} diff --git a/application/libraries/Usertokens.php b/application/libraries/Usertokens.php new file mode 100644 index 0000000..45bc4d3 --- /dev/null +++ b/application/libraries/Usertokens.php @@ -0,0 +1,135 @@ +usertokens->issue($userId, Tokens::RESET_PASSWORD); // goes into the e-mail + * $userId = $this->usertokens->peek(Tokens::RESET_PASSWORD, $plain); // look, do not use up + * $userId = $this->usertokens->consume(Tokens::RESET_PASSWORD, $plain); // use up, once + * + * consume() is a single UPDATE ... WHERE used_at IS NULL AND expires_at > now, so + * two requests with the same link can never both succeed. + * + * @property CI_DB_query_builder $db + */ +class Usertokens +{ + protected $CI; + + /** @var bool|null */ + protected $available; + + public function __construct() + { + $this->CI = &get_instance(); + } + + /** + * False while the migration has not run (the features that need links are then off). + */ + public function available() + { + if ($this->available === null) { + try { + $db = $this->db(); + $this->available = $db !== null && $db->table_exists('user_tokens') && $db->field_exists('email_verified_at', 'users'); + } catch (\Throwable $e) { + $this->available = false; + } + } + + return $this->available; + } + + /** + * Creates a link token for the user, replacing any unused one of the same kind. + * + * @return string the token to put in the link (it is not stored anywhere) + */ + public function issue($userId, $type) + { + $db = $this->db(); + $token = Tokens::generate(); + $now = time(); + + $db->where(['user_id' => (int) $userId, 'type' => $type, 'used_at' => null])->delete('user_tokens'); + $db->insert('user_tokens', [ + 'user_id' => (int) $userId, + 'type' => $type, + 'token_hash' => $token['hash'], + 'expires_at' => date('Y-m-d H:i:s', $now + Tokens::ttl($type)), + 'created_at' => date('Y-m-d H:i:s', $now), + ]); + + return $token['plain']; + } + + /** + * @return int|null the user a still-valid token belongs to, without using it up + */ + public function peek($type, $plain) + { + if (!Tokens::wellFormed($plain)) { + return null; + } + + $row = $this->db() + ->select('user_id') + ->where(['token_hash' => Tokens::hash($plain), 'type' => $type, 'used_at' => null]) + ->where('expires_at >', date('Y-m-d H:i:s')) + ->get('user_tokens')->row(); + + return $row ? (int) $row->user_id : null; + } + + /** + * Uses the token up. Only the first caller gets the user id back. + * + * @return int|null + */ + public function consume($type, $plain) + { + if (!Tokens::wellFormed($plain)) { + return null; + } + + $db = $this->db(); + $hash = Tokens::hash($plain); + $now = date('Y-m-d H:i:s'); + + $db->where(['token_hash' => $hash, 'type' => $type, 'used_at' => null]) + ->where('expires_at >', $now) + ->update('user_tokens', ['used_at' => $now]); + + if ($db->affected_rows() !== 1) { + return null; + } + + $row = $db->select('user_id')->get_where('user_tokens', ['token_hash' => $hash])->row(); + + return $row ? (int) $row->user_id : null; + } + + /** + * Cancels every unused link of a kind (after a password change, for example). + */ + public function revoke($userId, $type) + { + $this->db()->where(['user_id' => (int) $userId, 'type' => $type, 'used_at' => null])->delete('user_tokens'); + } + + protected function db() + { + if (!isset($this->CI->db) || !is_object($this->CI->db) || empty($this->CI->db->conn_id)) { + $this->CI->load->database(); + } + + return $this->CI->db; + } +} diff --git a/application/migrations/20261008000000_add_account_security.php b/application/migrations/20261008000000_add_account_security.php new file mode 100644 index 0000000..f444c3a --- /dev/null +++ b/application/migrations/20261008000000_add_account_security.php @@ -0,0 +1,50 @@ +dbforge->add_column('users', array( + 'email_verified_at' => array('type' => 'DATETIME', 'null' => true), + 'google_sub' => array('type' => 'VARCHAR', 'constraint' => 64, 'null' => true), + )); + $this->db->query('CREATE UNIQUE INDEX users_google_sub_unique ON users (google_sub)'); + $this->db->query('UPDATE users SET email_verified_at = COALESCE(created_at, ?) WHERE email_verified_at IS NULL', array(date('Y-m-d H:i:s'))); + + $this->dbforge->add_field(array( + 'id' => array('type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true), + 'user_id' => array('type' => 'INT', 'constraint' => 11, 'unsigned' => true), + 'type' => array('type' => 'VARCHAR', 'constraint' => 20), + 'token_hash' => array('type' => 'CHAR', 'constraint' => 64), + 'expires_at' => array('type' => 'DATETIME'), + 'used_at' => array('type' => 'DATETIME', 'null' => true), + 'created_at' => array('type' => 'DATETIME'), + )); + $this->dbforge->add_key('id', true); + $this->dbforge->create_table('user_tokens'); + $this->db->query('CREATE UNIQUE INDEX user_tokens_hash_unique ON user_tokens (token_hash)'); + $this->db->query('CREATE INDEX user_tokens_user_type ON user_tokens (user_id, type)'); + $this->db->query('CREATE INDEX user_tokens_expires ON user_tokens (expires_at)'); + } + + public function down() + { + $this->dbforge->drop_table('user_tokens', true); + $this->db->query('DROP INDEX users_google_sub_unique ON users'); + $this->dbforge->drop_column('users', 'google_sub'); + $this->dbforge->drop_column('users', 'email_verified_at'); + } +} diff --git a/application/modules/auth/controllers/Account.php b/application/modules/auth/controllers/Account.php new file mode 100644 index 0000000..6a4d7a9 --- /dev/null +++ b/application/modules/auth/controllers/Account.php @@ -0,0 +1,275 @@ + Accounts & e-mail) + * GET|POST /reset-password/{token} choose a new password + * GET|POST /verify-email/{token} confirm an address (GET shows a button, POST confirms) + * POST /resend-verification send a new confirmation link + * + * Rules that hold throughout: + * - Links carry a 64-hex-character random token; only its SHA-256 hash is stored (Usertokens). + * - What the visitor reads never depends on whether an address has an account. + * - Asking is rate limited per IP address and per address. + * - Following a link never logs anybody in; they log in with their password afterwards. + * - A confirmation link needs a POST, so mail scanners that open links cannot use it up. + * + * @property User_model $user_model + * @property Usertokens $usertokens + * @property Accountlinks $accountlinks + * @property Ratelimiter $ratelimiter + * @property Audit $audit + * @property Settings $settings + * @property Turnstile $turnstile + * @property Template $template + * @property CI_Form_validation $form_validation + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Session $session + */ +class Account extends MX_Controller +{ + public function __construct() + { + parent::__construct(); + // Nothing here may touch the database: every page of this controller must answer + // (with a 404) on an install where the features are off, with or without a database. + $this->load->library(['form_validation', 'ratelimiter', 'session']); + $this->load->helper(['form', 'url']); + } + + /** + * The database-backed pieces, loaded only once a feature is known to be on. + */ + protected function load_account_tools() + { + $this->load->model('user_model'); + $this->load->library(['usertokens', 'accountlinks']); + } + + // ------------------------------------------------------------------ + // Forgot password + // ------------------------------------------------------------------ + + public function forgot() + { + $this->feature('password_reset_enabled'); + + if ($this->input->method() !== 'post') { + $this->show('forgot_password', 'Forgot your password?'); + + return; + } + + $this->form_validation->set_rules('email', 'Email', 'required|valid_email|max_length[190]'); + if (!$this->form_validation->run()) { + $this->show('forgot_password', 'Forgot your password?'); + + return; + } + + if (!$this->turnstile->verify_for('forgot')) { + $this->show('forgot_password', 'Forgot your password?', ['error' => $this->turnstile->error_message()]); + + return; + } + + $email = strtolower(trim((string) $this->input->post('email'))); + if (!$this->allowed('forgot', $email, 5, 3)) { + $this->show('forgot_password', 'Forgot your password?', ['error' => 'Too many requests. Please try again later.']); + + return; + } + + $user = $this->user_model->find_by_email($email); + if ($user) { + $sent = $this->accountlinks->send_reset($user); + $this->audit->event('password.reset_requested', 'users', $user->id, $user->name.' ('.$user->email.')', ['mail_sent' => $sent ? 'yes' : 'no']); + } + + // The same answer whether or not the address has an account. + $this->show('forgot_password', 'Forgot your password?', ['msg' => 'If that address belongs to an account, a link to reset the password is on its way. It works for 1 hour.']); + } + + public function reset($token = '') + { + $this->feature('password_reset_enabled'); + $this->private_page(); + + $userId = $this->usertokens->peek(Tokens::RESET_PASSWORD, $token); + if ($userId === null) { + $this->show('link_invalid', 'Link not valid', ['what' => 'password reset']); + + return; + } + + if ($this->input->method() !== 'post') { + $this->show('reset_password', 'Choose a new password', ['token' => $token]); + + return; + } + + $this->form_validation->set_rules('password', 'Password', 'required|min_length[8]|max_length[255]'); + $this->form_validation->set_rules('password_confirm', 'Password confirmation', 'required|matches[password]'); + if (!$this->form_validation->run()) { + $this->show('reset_password', 'Choose a new password', ['token' => $token]); + + return; + } + + if (!$this->ratelimiter->attempt('reset-ip:'.$this->input->ip_address(), 10, 3600)) { + $this->show('reset_password', 'Choose a new password', ['token' => $token, 'error' => 'Too many attempts. Please try again later.']); + + return; + } + + // Use the link up first: if two requests race, only one gets the user. + $userId = $this->usertokens->consume(Tokens::RESET_PASSWORD, $token); + $user = $userId === null ? null : $this->user_model->find($userId); + if (!$user) { + $this->show('link_invalid', 'Link not valid', ['what' => 'password reset']); + + return; + } + + $changes = ['password' => password_hash((string) $this->input->post('password'), PASSWORD_DEFAULT)]; + // Whoever can read this mailbox owns the address, so it counts as confirmed now. + if ($this->user_model->has_verification() && empty($user->email_verified_at)) { + $changes['email_verified_at'] = date('Y-m-d H:i:s'); + } + $this->user_model->update($user->id, $changes); + $this->usertokens->revoke($user->id, Tokens::RESET_PASSWORD); + $this->audit->event('password.reset', 'users', $user->id, $user->name.' ('.$user->email.')'); + + flash('success', 'Your password was changed. Log in with the new one.'); + redirect('login'); + } + + // ------------------------------------------------------------------ + // E-mail verification + // ------------------------------------------------------------------ + + public function verify($token = '') + { + $this->private_page(); + if (!$this->settings->get('email_verification_enabled')) { + // Verification was switched off after the link was sent: nothing to confirm any more. + $this->show('link_invalid', 'Link not valid', ['what' => 'confirmation']); + + return; + } + $this->load_account_tools(); + if (!$this->usertokens->available()) { + show_404(); + } + + $userId = $this->usertokens->peek(Tokens::VERIFY_EMAIL, $token); + if ($userId === null) { + $this->show('link_invalid', 'Link not valid', ['what' => 'confirmation']); + + return; + } + + if ($this->input->method() !== 'post') { + $this->show('verify_email', 'Confirm your email', ['token' => $token]); + + return; + } + + $userId = $this->usertokens->consume(Tokens::VERIFY_EMAIL, $token); + $user = $userId === null ? null : $this->user_model->find($userId); + if (!$user) { + $this->show('link_invalid', 'Link not valid', ['what' => 'confirmation']); + + return; + } + + if (empty($user->email_verified_at)) { + $this->user_model->update($user->id, ['email_verified_at' => date('Y-m-d H:i:s')]); + $this->audit->event('email.verified', 'users', $user->id, $user->name.' ('.$user->email.')'); + } + + flash('success', 'Your email address is confirmed. You can log in now.'); + redirect('login'); + } + + public function resend() + { + if ($this->input->method() !== 'post') { + redirect('login'); + } + $this->feature('email_verification_enabled'); + + $this->form_validation->set_rules('email', 'Email', 'required|valid_email|max_length[190]'); + if (!$this->form_validation->run()) { + flash('error', 'Enter a valid email address.'); + redirect('login'); + } + + $email = strtolower(trim((string) $this->input->post('email'))); + if (!$this->allowed('resend', $email, 5, 3)) { + flash('error', 'Too many requests. Please try again later.'); + redirect('login'); + } + + $user = $this->user_model->find_by_email($email); + if ($user && empty($user->email_verified_at)) { + $this->accountlinks->send_verification($user); + } + + flash('success', 'If that address belongs to an account that still needs confirming, a new link is on its way.'); + redirect('login'); + } + + // ------------------------------------------------------------------ + + /** + * 404 unless the feature is switched on and its tables exist; signed-in people have no use for these pages. + */ + protected function feature($setting) + { + if (!$this->settings->get($setting)) { + show_404(); // before any database work + } + $this->load_account_tools(); + if (!$this->usertokens->available()) { + show_404(); + } + if (is_logged_in()) { + redirect('dashboard'); + } + } + + /** + * One attempt counts against the IP address and against the address typed, answering whether both are within limits. + */ + protected function allowed($kind, $email, $perIp, $perEmail) + { + $ip = $this->ratelimiter->attempt($kind.'-ip:'.$this->input->ip_address(), $perIp, 3600); + $mail = $this->ratelimiter->attempt($kind.'-mail:'.sha1($email), $perEmail, 3600); + + return $ip && $mail; + } + + /** + * Pages whose address contains a secret must not be cached or leak it through the Referer header. + */ + protected function private_page() + { + $this->output->set_header('Cache-Control: no-store')->set_header('Referrer-Policy: no-referrer'); + } + + protected function show($view, $title, array $data = []) + { + $this->template->set_title($title)->render($view, $data, 'auth'); + } +} diff --git a/application/modules/auth/controllers/Auth.php b/application/modules/auth/controllers/Auth.php index 671e750..41d41b7 100644 --- a/application/modules/auth/controllers/Auth.php +++ b/application/modules/auth/controllers/Auth.php @@ -16,7 +16,13 @@ * GET|POST /register * GET /logout * + * Related flows live in Account.php (e-mail verification, forgot/reset password) and + * Google_auth.php (Sign in with Google). + * * @property User_model $user_model + * @property Usertokens $usertokens + * @property Settings $settings + * @property Accountlinks $accountlinks * @property CI_Form_validation $form_validation * @property Ratelimiter $ratelimiter * @property Loginguard $loginguard @@ -111,6 +117,17 @@ protected function handle_login() $this->ratelimiter->reset('login:'.$this->input->ip_address()); } + // Only now (the password was right) do we say the address still needs confirming: + // nobody learns anything about an account without knowing its password. + if ($this->verification_required() && property_exists($user, 'email_verified_at') && empty($user->email_verified_at)) { + $this->show('login', 'Log in', [ + 'error' => 'Please confirm your email address first: follow the link we sent you.', + 'unverified_email' => $user->email, + ]); + + return; + } + $this->log_in_as($user); $this->audit->event('login', 'auth', $user->id, $user->name.' ('.$user->email.')'); @@ -151,16 +168,31 @@ protected function handle_register() return; } - $user_id = $this->user_model->create([ + // With "Require e-mail verification" on, the account waits for its link; otherwise it is confirmed from the start. + $verify = $this->verification_required(); + $data = [ 'name' => $this->input->post('name'), 'email' => $this->input->post('email'), 'password' => password_hash((string) $this->input->post('password'), PASSWORD_DEFAULT), 'role' => $this->rbac->default_role_slug(), // set on the Roles page - ]); + ]; + if ($this->user_model->has_verification()) { + $data['email_verified_at'] = $verify ? null : date('Y-m-d H:i:s'); + } + $user_id = $this->user_model->create($data); $created = $this->user_model->find($user_id); - $this->log_in_as($created); $this->audit->created('users', $user_id, $created->name.' ('.$created->email.')', $created); + + if ($verify) { + $this->load->library('accountlinks'); + $sent = $this->accountlinks->send_verification($created); + $this->show('check_email', 'Check your email', ['email' => $created->email, 'sent' => $sent]); + + return; + } + + $this->log_in_as($created); redirect('dashboard'); } @@ -185,6 +217,16 @@ protected function show($view, $title, array $data = []) $this->template->set_title($title)->render($view, $data, 'auth'); } + /** + * Whether new and existing accounts must confirm their e-mail address before logging in. + */ + protected function verification_required() + { + $this->load->library('usertokens'); + + return (bool) $this->settings->get('email_verification_enabled') && $this->usertokens->available(); + } + /** * What the visitor reads when they are (or just became) blocked. */ diff --git a/application/modules/auth/controllers/Google_auth.php b/application/modules/auth/controllers/Google_auth.php new file mode 100644 index 0000000..a3aa88b --- /dev/null +++ b/application/modules/auth/controllers/Google_auth.php @@ -0,0 +1,225 @@ +load->library('session'); + $this->load->helper(['url']); + } + + public function login() + { + if (is_logged_in()) { + redirect('dashboard'); + } + $oauth = $this->client(); + + $state = GoogleOAuth::randomToken(); + $nonce = GoogleOAuth::randomToken(); + $verifier = GoogleOAuth::newVerifier(); + $this->session->set_userdata(self::SESSION_KEY, ['state' => $state, 'nonce' => $nonce, 'verifier' => $verifier, 'at' => time()]); + + $this->output->set_header('Cache-Control: no-store'); + redirect($oauth->authorizationUrl($state, $nonce, $verifier), 'location', 302); + } + + public function callback() + { + if (is_logged_in()) { + redirect('dashboard'); + } + $oauth = $this->client(); + + // The sign-in we started is single use, whatever happens next. + $started = $this->session->userdata(self::SESSION_KEY); + $this->session->unset_userdata(self::SESSION_KEY); + + $this->output->set_header('Cache-Control: no-store'); + + $state = $this->input->get('state'); + if (!is_array($started) || !isset($started['state'], $started['nonce'], $started['verifier'], $started['at']) + || (time() - (int) $started['at']) > self::MAX_AGE + || !is_string($state) || $state === '' || !hash_equals((string) $started['state'], $state)) { + $this->fail('Your Google sign-in expired or did not start here. Please try again.', 'state'); + } + + if ($this->input->get('error') !== null) { + $this->fail('Google sign-in was cancelled.', 'google said: '.substr((string) $this->input->get('error'), 0, 40)); + } + + $code = $this->input->get('code'); + if (!is_string($code) || $code === '' || strlen($code) > 2048) { + $this->fail('Google did not return a sign-in code. Please try again.', 'no code'); + } + + $result = $oauth->exchange($code, (string) $started['verifier']); + $claims = $result['claims']; + if ($claims === null) { + $this->fail('Google could not be reached or refused the sign-in. Please try again.', $result['error']); + } + + $problem = $oauth->validate($claims, (string) $started['nonce'], time()); + if ($problem !== '') { + $this->fail($problem === 'e-mail not verified by Google' + ? 'Google has not verified that email address, so it cannot be used to sign in.' + : 'Google sign-in failed. Please try again.', $problem); + } + + $user = $this->resolve_user($claims); + + $guard = $this->loginguard; + if ($guard->available() && $guard->seconds_blocked((int) $user->id) > 0) { + $this->fail('Too many failed login attempts. Please try again later.', 'blocked'); + } + + $this->session->set_userdata([ + 'user_id' => $user->id, + 'user_name' => $user->name, + 'user_email' => $user->email, + 'user_role' => $user->role, + ]); + $this->audit->event('login', 'auth', $user->id, $user->name.' ('.$user->email.')', ['via' => 'google']); + + $redirect = $this->session->flashdata('redirect_after_login'); + redirect($redirect ?: 'dashboard'); + } + + /** + * Finds, links or creates the account for validated claims. + * + * @param array $claims + * + * @return object + */ + protected function resolve_user(array $claims) + { + $sub = (string) $claims['sub']; + $email = strtolower((string) $claims['email']); + $now = date('Y-m-d H:i:s'); + + $user = $this->user_model->find_by_google_sub($sub); + if ($user) { + return $user; + } + + $user = $this->user_model->find_by_email($email); + if ($user) { + if (!empty($user->google_sub)) { + // The address belongs to an account tied to a different Google identity. + $this->fail('That email address is linked to another Google account.', 'sub mismatch for user '.$user->id); + } + + $changes = ['google_sub' => $sub]; + if (empty($user->email_verified_at)) { + $changes['email_verified_at'] = $now; + $changes['password'] = password_hash(bin2hex(random_bytes(32)), PASSWORD_DEFAULT); + } + $this->user_model->update($user->id, $changes); + $this->audit->event('google.linked', 'users', $user->id, $user->name.' ('.$user->email.')', [ + 'password_replaced' => isset($changes['password']) ? 'yes' : 'no', + ]); + + return $this->user_model->find($user->id); + } + + $name = trim(preg_replace('/\s+/', ' ', strip_tags((string) ($claims['name'] ?? '')))); + $name = mb_substr($name !== '' ? $name : strstr($email, '@', true), 0, 100); + + $id = $this->user_model->create([ + 'name' => $name, + 'email' => $email, + 'password' => password_hash(bin2hex(random_bytes(32)), PASSWORD_DEFAULT), + 'role' => $this->rbac->default_role_slug(), + 'email_verified_at' => $now, + 'google_sub' => $sub, + ]); + $created = $this->user_model->find($id); + $this->audit->created('users', $id, $created->name.' ('.$created->email.')', ['name' => $created->name, 'email' => $created->email, 'role' => $created->role, 'via' => 'google']); + + return $created; + } + + /** + * @return GoogleOAuth + */ + protected function client() + { + $id = trim((string) $this->settings->get('google_client_id')); + $secret = trim((string) $this->settings->get('google_client_secret')); + + if (!$this->settings->get('google_enabled') || $id === '' || $secret === '') { + show_404(); + } + + $this->load->model('user_model'); + $this->load->library('loginguard'); + if (!$this->user_model->has_verification()) { + show_404(); // the account-security migration has not run + } + + // Test servers only: point the flow at a fake identity provider. + $auth = $token = $issuers = null; + if (ENVIRONMENT !== 'production' && getenv('GOOGLE_TEST_BASE')) { + $base = rtrim((string) getenv('GOOGLE_TEST_BASE'), '/'); + $auth = $base.'/auth'; + $token = $base.'/token'; + $issuers = [$base]; + } + + return new GoogleOAuth($id, $secret, site_url('google/callback'), $auth, $token, null, $issuers); + } + + /** + * Logs the real reason, shows a plain one, and goes back to the login page. + */ + protected function fail($message, $reason) + { + log_message('error', 'Google sign-in refused: '.$reason); + flash('error', $message); + redirect('login'); + } +} diff --git a/application/modules/auth/models/User_model.php b/application/modules/auth/models/User_model.php index b08339f..4c7703d 100644 --- a/application/modules/auth/models/User_model.php +++ b/application/modules/auth/models/User_model.php @@ -32,6 +32,27 @@ public function find_by_email($email) return $this->db->get_where($this->table, ['email' => $email])->row(); } + public function find_by_google_sub($sub) + { + return $this->db->get_where($this->table, ['google_sub' => (string) $sub])->row(); + } + + /** + * Whether this install can tell verified from unverified addresses + * (false until the account-security migration has run). + */ + public function has_verification() + { + return $this->db->field_exists('email_verified_at', $this->table); + } + + public function update($id, array $data) + { + $data['updated_at'] = date('Y-m-d H:i:s'); + + return $this->db->where('id', (int) $id)->update($this->table, $data); + } + public function create(array $data) { $data['created_at'] = date('Y-m-d H:i:s'); diff --git a/application/modules/auth/views/check_email.php b/application/modules/auth/views/check_email.php new file mode 100644 index 0000000..dff5504 --- /dev/null +++ b/application/modules/auth/views/check_email.php @@ -0,0 +1,9 @@ + + +

Check your email

+ +

We sent a confirmation link to . It works for 3 days; open it and confirm, then log in.

+ + + +Go to log in diff --git a/application/modules/auth/views/forgot_password.php b/application/modules/auth/views/forgot_password.php new file mode 100644 index 0000000..c6fc556 --- /dev/null +++ b/application/modules/auth/views/forgot_password.php @@ -0,0 +1,22 @@ + +

Forgot your password?

+

Enter your email address and we will send you a link to choose a new one.

+ + +
+ + + 'auth-form']) ?> +
+ +
+ + +
+
+ + + + + +

Back to log in

diff --git a/application/modules/auth/views/link_invalid.php b/application/modules/auth/views/link_invalid.php new file mode 100644 index 0000000..fc89050 --- /dev/null +++ b/application/modules/auth/views/link_invalid.php @@ -0,0 +1,11 @@ + + +

This link does not work

+

The link is wrong, has expired, or was already used.

+ + + Ask for a new link + +

Log in with your password: if your address still needs confirming, you will be offered a new link.

+ Go to log in + diff --git a/application/modules/auth/views/login.php b/application/modules/auth/views/login.php index bc01bfa..7b633ff 100644 --- a/application/modules/auth/views/login.php +++ b/application/modules/auth/views/login.php @@ -20,8 +20,21 @@ + +
Forgot your password?
+ + + + 'auth-form mt-3']) ?> + + + + + + +

New here? Create an account

diff --git a/application/modules/auth/views/register.php b/application/modules/auth/views/register.php index 607a88f..a182b80 100644 --- a/application/modules/auth/views/register.php +++ b/application/modules/auth/views/register.php @@ -32,4 +32,6 @@ + +

Already have an account? Log in

diff --git a/application/modules/auth/views/reset_password.php b/application/modules/auth/views/reset_password.php new file mode 100644 index 0000000..159ce65 --- /dev/null +++ b/application/modules/auth/views/reset_password.php @@ -0,0 +1,25 @@ + + +

Choose a new password

+

At least 8 characters. You will log in with it afterwards.

+ + 'auth-form']) ?> +
+ +
+ + + +
+
+ +
+ +
+ + +
+
+ + + diff --git a/application/modules/auth/views/verify_email.php b/application/modules/auth/views/verify_email.php new file mode 100644 index 0000000..975a5ba --- /dev/null +++ b/application/modules/auth/views/verify_email.php @@ -0,0 +1,8 @@ + + +

Confirm your email

+

One more click to finish setting up your account.

+ + 'auth-form']) ?> + + diff --git a/application/seeds/users_seeder.php b/application/seeds/users_seeder.php index 4da0334..38d2bf6 100644 --- a/application/seeds/users_seeder.php +++ b/application/seeds/users_seeder.php @@ -19,14 +19,18 @@ public function run() return; } - $this->CI->db->insert('users', array( + $user = array( 'name' => 'Admin', 'email' => 'admin@example.com', 'password' => password_hash('password', PASSWORD_DEFAULT), 'role' => 'super_admin', 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), - )); + ); + if ($this->CI->db->field_exists('email_verified_at', 'users')) { + $user['email_verified_at'] = date('Y-m-d H:i:s'); // the seeded admin never waits for a link + } + $this->CI->db->insert('users', $user); echo 'Seeded default admin: admin@example.com / password — change this before deploying anywhere real.'.PHP_EOL; } diff --git a/application/src/Audit/Labels.php b/application/src/Audit/Labels.php index 35ae8db..1ed24f0 100644 --- a/application/src/Audit/Labels.php +++ b/application/src/Audit/Labels.php @@ -22,6 +22,10 @@ public static function action($action) 'login.unblocked' => ['Unblocked', 'success'], 'install' => ['Installed', 'primary'], 'export' => ['Exported', 'primary'], + 'password.reset_requested' => ['Password reset asked', 'secondary'], + 'password.reset' => ['Password reset', 'primary'], + 'email.verified' => ['Email confirmed', 'success'], + 'google.linked' => ['Google linked', 'primary'], ]; if (isset($known[$action])) { diff --git a/application/src/Auth/GoogleOAuth.php b/application/src/Auth/GoogleOAuth.php new file mode 100644 index 0000000..3c94907 --- /dev/null +++ b/application/src/Auth/GoogleOAuth.php @@ -0,0 +1,233 @@ +, int): ?array{status: int, body: string} */ + private $http; + + /** @var array */ + private $issuers; + + /** + * @param callable|null $http fn(string $url, array $form, int $timeout): ?array{status:int, body:string}; + * null on a transport failure. Defaults to cURL. + * @param array|null $issuers the `iss` values accepted; Google's two by default (tests use their own) + */ + public function __construct($clientId, $clientSecret, $redirectUri, $authUrl = null, $tokenUrl = null, $http = null, $issuers = null) + { + $this->issuers = $issuers ?: ['https://accounts.google.com', 'accounts.google.com']; + $this->clientId = (string) $clientId; + $this->clientSecret = (string) $clientSecret; + $this->redirectUri = (string) $redirectUri; + $this->authUrl = $authUrl ?: self::AUTH_URL; + $this->tokenUrl = $tokenUrl ?: self::TOKEN_URL; + $this->http = $http ?: [self::class, 'post']; + } + + /** + * @return string a random value for `state` or `nonce` + */ + public static function randomToken() + { + return bin2hex(random_bytes(24)); + } + + /** + * @return string the PKCE code verifier: 43 URL-safe characters (RFC 7636) + */ + public static function newVerifier() + { + return self::base64url(random_bytes(32)); + } + + /** + * @return string the S256 PKCE challenge for a verifier (RFC 7636, section 4.2) + */ + public static function challenge($verifier) + { + return self::base64url(hash('sha256', (string) $verifier, true)); + } + + /** + * @return string + */ + public function authorizationUrl($state, $nonce, $verifier) + { + return $this->authUrl.'?'.http_build_query([ + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUri, + 'response_type' => 'code', + 'scope' => 'openid email profile', + 'state' => $state, + 'nonce' => $nonce, + 'code_challenge' => self::challenge($verifier), + 'code_challenge_method' => 'S256', + 'prompt' => 'select_account', + ], '', '&', PHP_QUERY_RFC3986); + } + + /** + * Trades the authorization code for the visitor's claims. + * + * @return array{claims: array|null, error: string} + */ + public function exchange($code, $verifier) + { + $response = ($this->http)($this->tokenUrl, [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'code' => (string) $code, + 'code_verifier' => (string) $verifier, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->redirectUri, + ], 8); + + if ($response === null) { + return ['claims' => null, 'error' => 'transport']; + } + + $body = json_decode($response['body'], true); + if ($response['status'] !== 200 || !is_array($body) || empty($body['id_token']) || !is_string($body['id_token'])) { + $reason = is_array($body) && isset($body['error']) && is_string($body['error']) ? substr($body['error'], 0, 60) : 'http '.$response['status']; + + return ['claims' => null, 'error' => 'token endpoint: '.$reason]; + } + + $claims = self::claims($body['id_token']); + + return $claims === null ? ['claims' => null, 'error' => 'unreadable id_token'] : ['claims' => $claims, 'error' => '']; + } + + /** + * The claims of a JWT's payload. The signature is NOT checked, see the class comment. + * + * @return array|null + */ + public static function claims($jwt) + { + $parts = explode('.', (string) $jwt); + if (count($parts) !== 3) { + return null; + } + + $json = base64_decode(strtr($parts[1], '-_', '+/'), true); + $claims = $json === false ? null : json_decode($json, true); + + return is_array($claims) ? $claims : null; + } + + /** + * Whether the claims describe a sign-in we may accept. + * + * @param array $claims + * + * @return string an empty string when fine, otherwise the reason (for the log, not for the visitor) + */ + public function validate(array $claims, $nonce, $now) + { + if (!in_array($claims['iss'] ?? null, $this->issuers, true)) { + return 'wrong issuer'; + } + + $aud = $claims['aud'] ?? null; + if (!(is_string($aud) && hash_equals($this->clientId, $aud)) && !(is_array($aud) && in_array($this->clientId, $aud, true))) { + return 'token is for another client'; + } + if (is_array($aud) && ($claims['azp'] ?? null) !== $this->clientId) { + return 'authorized party mismatch'; + } + + if (!isset($claims['exp']) || !is_numeric($claims['exp']) || (int) $claims['exp'] <= (int) $now) { + return 'token expired'; + } + + if (!isset($claims['nonce']) || !is_string($claims['nonce']) || !hash_equals((string) $nonce, $claims['nonce'])) { + return 'nonce mismatch'; + } + + if (empty($claims['sub']) || !is_string($claims['sub']) || strlen($claims['sub']) > 64) { + return 'no subject'; + } + + if (empty($claims['email']) || !is_string($claims['email']) || strlen($claims['email']) > 190 || filter_var($claims['email'], FILTER_VALIDATE_EMAIL) === false) { + return 'no usable e-mail'; + } + + // Google also sends the string "true" in some older tokens. + if (($claims['email_verified'] ?? false) !== true && ($claims['email_verified'] ?? null) !== 'true') { + return 'e-mail not verified by Google'; + } + + return ''; + } + + private static function base64url($raw) + { + return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + } + + /** + * @param array $form + * + * @return array{status: int, body: string}|null + */ + public static function post($url, array $form, $timeout) + { + if (!function_exists('curl_init')) { + return null; + } + + $curl = curl_init($url); + curl_setopt_array($curl, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($form), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => (int) $timeout, + CURLOPT_CONNECTTIMEOUT => 4, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + $body = curl_exec($curl); + $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + curl_close($curl); + + return is_string($body) ? ['status' => $status, 'body' => $body] : null; + } +} diff --git a/application/src/Auth/Tokens.php b/application/src/Auth/Tokens.php new file mode 100644 index 0000000..c169f8e --- /dev/null +++ b/application/src/Auth/Tokens.php @@ -0,0 +1,59 @@ + 259200, // 3 days + self::RESET_PASSWORD => 3600, // 1 hour + ]; + + /** + * @return array{plain: string, hash: string} "plain" goes into the e-mail, "hash" into the database + */ + public static function generate() + { + $plain = bin2hex(random_bytes(32)); + + return ['plain' => $plain, 'hash' => self::hash($plain)]; + } + + public static function hash($plain) + { + return hash('sha256', (string) $plain); + } + + /** + * Whether a string has the shape of a token, checked before the database is asked. + */ + public static function wellFormed($plain) + { + return is_string($plain) && preg_match('/^[a-f0-9]{64}$/', $plain) === 1; + } + + public static function isType($type) + { + return isset(self::TTL[$type]); + } + + /** + * @return int seconds + */ + public static function ttl($type) + { + return self::TTL[$type] ?? 3600; + } +} diff --git a/assets/css/app.css b/assets/css/app.css index 9c4acaa..4f66adc 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -558,3 +558,8 @@ table.app-datatable > tbody > tr.child span.dtr-data { flex: 1 1 10rem; min-widt .crud-thumb-lg { width: 4rem; height: 4rem; } .crud-checklist { max-height: 12rem; overflow-y: auto; } .g-field .card-header { background: var(--bs-tertiary-bg); } + +/* Auth pages: "or" divider and the Google button */ +.auth-or { display: flex; align-items: center; gap: .75rem; margin: 1.25rem 0; color: var(--bs-secondary-color); font-size: .85rem; } +.auth-or::before, .auth-or::after { content: ""; flex: 1; border-top: 1px solid var(--bs-border-color); } +.btn-google { padding-top: .72rem; padding-bottom: .72rem; } diff --git a/tests-app/Feature/HttpSmokeTest.php b/tests-app/Feature/HttpSmokeTest.php index b4a8a9e..6045519 100644 --- a/tests-app/Feature/HttpSmokeTest.php +++ b/tests-app/Feature/HttpSmokeTest.php @@ -130,6 +130,21 @@ public function testAdminPagesRedirectGuestsToTheLogin(): void } } + public function testAccountPagesDoNotExistUntilTheirSettingsAreOn(): void + { + $client = new Client(['base_uri' => self::$baseUri, 'http_errors' => false]); + + // Forgot password, Google sign-in and e-mail verification are all off by default. + foreach (['/forgot-password', '/google/login', '/google/callback', '/reset-password/'.str_repeat('a', 64)] as $path) { + $this->assertSame(404, $client->get($path)->getStatusCode(), $path); + } + // A link that is not even shaped like a token never reaches a controller. + $this->assertSame(404, $client->get('/reset-password/short')->getStatusCode()); + // Nothing to resend while verification is off: a redirect to the login page (not followed here). + $noFollow = new Client(['base_uri' => self::$baseUri, 'http_errors' => false, 'allow_redirects' => false]); + $this->assertContains($noFollow->get('/resend-verification')->getStatusCode(), [301, 302, 303, 307, 308]); + } + public function testUnknownRouteRendersCustom404Page(): void { $client = new Client(['base_uri' => self::$baseUri, 'http_errors' => false]); diff --git a/tests-app/Unit/AuthTokensTest.php b/tests-app/Unit/AuthTokensTest.php new file mode 100644 index 0000000..4e631d9 --- /dev/null +++ b/tests-app/Unit/AuthTokensTest.php @@ -0,0 +1,53 @@ +assertSame(1, preg_match('/^[a-f0-9]{64}$/', $t['plain'])); + $this->assertSame(64, strlen($t['hash'])); + $this->assertNotSame($t['plain'], $t['hash']); + $this->assertSame(hash('sha256', $t['plain']), $t['hash']); + $this->assertSame($t['hash'], Tokens::hash($t['plain'])); + } + + public function testTokensDoNotRepeat(): void + { + $seen = []; + for ($i = 0; $i < 500; ++$i) { + $seen[Tokens::generate()['plain']] = true; + } + + $this->assertCount(500, $seen); + } + + public function testWellFormedAcceptsOnlyLowercaseHexOfTheRightLength(): void + { + $this->assertTrue(Tokens::wellFormed(str_repeat('a', 64))); + $this->assertTrue(Tokens::wellFormed(Tokens::generate()['plain'])); + + $this->assertFalse(Tokens::wellFormed(str_repeat('a', 63))); + $this->assertFalse(Tokens::wellFormed(str_repeat('a', 65))); + $this->assertFalse(Tokens::wellFormed(str_repeat('A', 64))); + $this->assertFalse(Tokens::wellFormed(str_repeat('g', 64))); + $this->assertFalse(Tokens::wellFormed(str_repeat('a', 63)."\n")); + $this->assertFalse(Tokens::wellFormed('')); + $this->assertFalse(Tokens::wellFormed(null)); + $this->assertFalse(Tokens::wellFormed(['a'])); + } + + public function testLifetimes(): void + { + $this->assertSame(3600, Tokens::ttl(Tokens::RESET_PASSWORD), 'a reset link lives one hour'); + $this->assertSame(259200, Tokens::ttl(Tokens::VERIFY_EMAIL), 'a verification link lives three days'); + $this->assertTrue(Tokens::isType('verify_email')); + $this->assertFalse(Tokens::isType('anything_else')); + } +} diff --git a/tests-app/Unit/GoogleOAuthTest.php b/tests-app/Unit/GoogleOAuthTest.php new file mode 100644 index 0000000..d150776 --- /dev/null +++ b/tests-app/Unit/GoogleOAuthTest.php @@ -0,0 +1,162 @@ + + */ + private function claims(array $override = []): array + { + return $override + [ + 'iss' => 'https://accounts.google.com', + 'aud' => self::CLIENT, + 'exp' => self::NOW + 600, + 'nonce' => 'n-1', + 'sub' => '1100223344', + 'email' => 'ann@example.com', + 'email_verified' => true, + 'name' => 'Ann', + ]; + } + + private function jwt(array $claims): string + { + $b = static function (string $s): string { + return rtrim(strtr(base64_encode($s), '+/', '-_'), '='); + }; + + return $b('{"alg":"none"}').'.'.$b((string) json_encode($claims)).'.sig'; + } + + // ---- PKCE ---------------------------------------------------------- + + public function testPkceChallengeMatchesTheRfc7636Example(): void + { + $this->assertSame('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', GoogleOAuth::challenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')); + } + + public function testVerifierAndRandomTokensAreLongAndUrlSafe(): void + { + $v = GoogleOAuth::newVerifier(); + $this->assertSame(1, preg_match('/^[A-Za-z0-9_-]{43}$/', $v)); + $this->assertNotSame($v, GoogleOAuth::newVerifier()); + $this->assertSame(1, preg_match('/^[a-f0-9]{48}$/', GoogleOAuth::randomToken())); + } + + // ---- The URL to Google ----------------------------------------------- + + public function testAuthorizationUrlCarriesStateNonceAndPkce(): void + { + $url = $this->oauth()->authorizationUrl('STATE', 'NONCE', 'VERIFIER'); + parse_str((string) parse_url($url, PHP_URL_QUERY), $q); + + $this->assertStringStartsWith('https://accounts.google.com/o/oauth2/v2/auth?', $url); + $this->assertSame(self::CLIENT, $q['client_id']); + $this->assertSame('https://app.test/google/callback', $q['redirect_uri']); + $this->assertSame('code', $q['response_type']); + $this->assertSame('openid email profile', $q['scope']); + $this->assertSame('STATE', $q['state'], 'the state must really be sent'); + $this->assertSame('NONCE', $q['nonce']); + $this->assertSame(GoogleOAuth::challenge('VERIFIER'), $q['code_challenge']); + $this->assertSame('S256', $q['code_challenge_method']); + $this->assertArrayNotHasKey('access_type', $q, 'no refresh token is asked for'); + $this->assertStringNotContainsString('VERIFIER', $url, 'the verifier itself never leaves the server'); + } + + // ---- Claims -------------------------------------------------------------- + + public function testAcceptsGoodClaims(): void + { + $this->assertSame('', $this->oauth()->validate($this->claims(), 'n-1', self::NOW)); + $this->assertSame('', $this->oauth()->validate($this->claims(['iss' => 'accounts.google.com']), 'n-1', self::NOW)); + $this->assertSame('', $this->oauth()->validate($this->claims(['email_verified' => 'true']), 'n-1', self::NOW)); + $this->assertSame('', $this->oauth()->validate($this->claims(['aud' => [self::CLIENT], 'azp' => self::CLIENT]), 'n-1', self::NOW)); + } + + public function testRejectsEverythingThatIsNotRight(): void + { + $o = $this->oauth(); + + $this->assertSame('wrong issuer', $o->validate($this->claims(['iss' => 'https://evil.example']), 'n-1', self::NOW)); + $this->assertSame('wrong issuer', $o->validate(array_diff_key($this->claims(), ['iss' => 1]), 'n-1', self::NOW)); + $this->assertSame('token is for another client', $o->validate($this->claims(['aud' => 'someone-else']), 'n-1', self::NOW)); + $this->assertSame('authorized party mismatch', $o->validate($this->claims(['aud' => [self::CLIENT, 'x'], 'azp' => 'x']), 'n-1', self::NOW)); + $this->assertSame('token expired', $o->validate($this->claims(['exp' => self::NOW]), 'n-1', self::NOW)); + $this->assertSame('token expired', $o->validate(array_diff_key($this->claims(), ['exp' => 1]), 'n-1', self::NOW)); + $this->assertSame('nonce mismatch', $o->validate($this->claims(), 'another', self::NOW)); + $this->assertSame('nonce mismatch', $o->validate(array_diff_key($this->claims(), ['nonce' => 1]), 'n-1', self::NOW)); + $this->assertSame('no subject', $o->validate($this->claims(['sub' => '']), 'n-1', self::NOW)); + $this->assertSame('no usable e-mail', $o->validate($this->claims(['email' => 'not-an-email']), 'n-1', self::NOW)); + $this->assertSame('no usable e-mail', $o->validate($this->claims(['email' => ['a@b.c']]), 'n-1', self::NOW)); + $this->assertSame('e-mail not verified by Google', $o->validate($this->claims(['email_verified' => false]), 'n-1', self::NOW)); + $this->assertSame('e-mail not verified by Google', $o->validate($this->claims(['email_verified' => 'yes']), 'n-1', self::NOW)); + $this->assertSame('e-mail not verified by Google', $o->validate(array_diff_key($this->claims(), ['email_verified' => 1]), 'n-1', self::NOW)); + } + + // ---- Exchanging the code ------------------------------------------------------ + + public function testExchangeSendsTheCodeAndVerifierAndReadsTheIdToken(): void + { + $seen = null; + $o = $this->oauth(function ($url, $form, $timeout) use (&$seen) { + $seen = [$url, $form]; + + return ['status' => 200, 'body' => (string) json_encode(['id_token' => $this->jwt($this->claims()), 'access_token' => 'x'])]; + }); + + $r = $o->exchange('CODE', 'VERIFIER'); + + $this->assertSame('', $r['error']); + $this->assertSame('ann@example.com', $r['claims']['email']); + $this->assertSame('https://oauth2.googleapis.com/token', $seen[0]); + $this->assertSame('CODE', $seen[1]['code']); + $this->assertSame('VERIFIER', $seen[1]['code_verifier']); + $this->assertSame('authorization_code', $seen[1]['grant_type']); + $this->assertSame('secret', $seen[1]['client_secret']); + } + + public function testExchangeFailuresAreReportedNotThrown(): void + { + $down = $this->oauth(function () { + return null; + }); + $this->assertSame('transport', $down->exchange('c', 'v')['error']); + + $bad = $this->oauth(function () { + return ['status' => 400, 'body' => '{"error":"invalid_grant"}']; + }); + $this->assertSame('token endpoint: invalid_grant', $bad->exchange('c', 'v')['error']); + + $noToken = $this->oauth(function () { + return ['status' => 200, 'body' => '{"access_token":"x"}']; + }); + $this->assertSame('token endpoint: http 200', $noToken->exchange('c', 'v')['error']); + + $junk = $this->oauth(function () { + return ['status' => 200, 'body' => '{"id_token":"not.a-jwt"}']; + }); + $this->assertSame('unreadable id_token', $junk->exchange('c', 'v')['error']); + } + + public function testCustomIssuersForTests(): void + { + $o = new GoogleOAuth(self::CLIENT, 's', 'https://app.test/cb', 'http://127.0.0.1:9/auth', 'http://127.0.0.1:9/token', null, ['http://127.0.0.1:9']); + + $this->assertSame('', $o->validate($this->claims(['iss' => 'http://127.0.0.1:9']), 'n-1', self::NOW)); + $this->assertSame('wrong issuer', $o->validate($this->claims(), 'n-1', self::NOW)); + $this->assertStringStartsWith('http://127.0.0.1:9/auth?', $o->authorizationUrl('s', 'n', 'v')); + } +}