Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions application/config/app_settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
29 changes: 29 additions & 0 deletions application/config/email.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

defined('BASEPATH') or exit('No direct script access allowed');

/*
| Mail settings for CodeIgniter's Email class, all from .env (nothing secret in this file):
|
| MAIL_PROTOCOL mail (PHP's mail(), default) | smtp | sendmail | log (development only: writes
| every e-mail to application/logs/mail-YYYY-MM-DD.log instead of sending it)
| SMTP_HOST, SMTP_PORT (587), SMTP_USER, SMTP_PASS, SMTP_CRYPTO (tls | ssl | empty)
| MAIL_FROM_ADDRESS, MAIL_FROM_NAME defaults for Settings -> 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;
6 changes: 6 additions & 0 deletions application/config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion application/controllers/Console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')) {
Expand All @@ -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;
}
Expand All @@ -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).
*/
Expand Down
38 changes: 38 additions & 0 deletions application/helpers/auth_helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<div class="auth-or" role="separator"><span>or</span></div>'
.'<a href="'.htmlspecialchars(site_url('google/login'), ENT_QUOTES, 'UTF-8').'" class="btn btn-outline-secondary w-100 btn-google">'
.'<i class="bi bi-google me-2"></i>'.htmlspecialchars($label, ENT_QUOTES, 'UTF-8').'</a>';
}
}

if (!function_exists('password_reset_enabled')) {
function password_reset_enabled()
{
return (bool) app_setting('password_reset_enabled');
}
}
66 changes: 66 additions & 0 deletions application/libraries/Accountlinks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

defined('BASEPATH') or exit('No direct script access allowed');

require_once APPPATH.'src/autoload.php';

use App\Auth\Tokens;

/**
* Makes the e-mailed links for an account and sends them: the one that confirms
* an address and the one that resets a password.
*
* $sent = $this->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";
}
}
1 change: 1 addition & 0 deletions application/libraries/Installer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
Expand Down
Loading
Loading