Skip to content
Nibiru docsv0.9.2

Auth

Session-based authentication, the prebuilt login form, and the User plugin pattern from production.

Stable Reading time ~ 3 min Edit on GitHub

Nibiru ships a session-based authentication core (Nibiru\Auth) and a users module that gives you a working login form, an authorisation check, and the database schema in three commands.

Auth::auth($login, $password) is the lowest-level call. It:

  1. Looks up the user by user_login.
  2. Decrypts the stored password using the salt from [SECURITY] password_hash.
  3. On match, replaces $_SESSION with ['auth' => ['session_id' => …, 'user_id' => …, 'login' => …]].
$auth = new \Nibiru\Auth();
if ($auth->auth($_POST['login'], $_POST['password'])) {
View::forwardTo('/dashboard');
} else {
View::assign(['error' => 'Invalid credentials.']);
}

Generate it once with the CLI:

Terminal window
./nibiru -m users

Then run the matching migrations:

Terminal window
./nibiru -mi local

You now have:

  • application/module/users/ — the module folder.
  • users table — created by 005-user.sql.
  • User plugin — Nibiru\Module\Users\Plugin\User with isAuthorized(), loginForm(), currentUser().

application/controller/loginController.php:

<?php
namespace Nibiru;
use Nibiru\Adapter\Controller;
use Nibiru\Module\Users\Plugin\User;
class loginController extends Controller
{
private User $user;
public function __construct() {
parent::__construct();
$this->user = new User();
}
public function pageAction() {
if ($this->user->isAuthorized()) {
View::forwardTo('/');
return;
}
View::assign([
'title' => 'Sign in',
'loginForm' => $this->user->loginForm(),
]);
}
public function submitAction() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
$auth = new Auth();
if ($auth->auth($_POST['login'] ?? '', $_POST['password'] ?? '')) {
View::forwardTo('/');
} else {
View::assign(['error' => 'Invalid login.']);
}
}
public function logoutAction() {
unset($_SESSION['auth']);
session_regenerate_id(true);
View::forwardTo('/login');
}
public function navigationAction() {
JsonNavigation::getInstance()->loadJsonNavigationArray();
}
}

application/view/templates/login.tpl:

{include 'shared/header.tpl'}
<body>
{include file="navigation.tpl"}
<main class="container">
<h1>{$title}</h1>
{if $error}<div class="alert alert-danger">{$error}</div>{/if}
{$loginForm nofilter}
</main>
{include 'shared/footer.tpl'}
</body>

Form::action="/login/submit" is set inside loginForm() so the form posts to the right action.

The simple check:

public function pageAction() {
if (!$this->user->isAuthorized()) {
View::forwardTo('/login');
return;
}
/ ...
}

For role-aware checks, the showcase apps use the Acl plugin from the same module:

use Nibiru\Module\Users\Plugin\Acl;
if (!Acl::can('edit', 'documents')) {
View::forwardTo('/forbidden');
return;
}

Tables acl, user_to_acl, acl-data (migrations 001, 008, 011) form the role/permission base.

For production, replace Auth::auth() with a hardened version:

namespace Nibiru;
use Nibiru\Pdo;
class HardenedAuth
{
public function auth(string $login, string $password): bool {
$row = Pdo::fetchRow(
'SELECT user_id, user_pass FROM "user" WHERE user_login = :l AND user_account_active = 1',
[':l' => $login]
);
if (!$row || !password_verify($password, $row['user_pass'])) {
return false;
}
if (password_needs_rehash($row['user_pass'], PASSWORD_ARGON2ID)) {
Pdo::update('user', [
'user_pass' => password_hash($password, PASSWORD_ARGON2ID),
], ['user_id' => $row['user_id']]);
}
session_regenerate_id(true);
$_SESSION['auth'] = [
'session_id' => session_id(),
'user_id' => $row['user_id'],
'login' => $login,
];
return true;
}
}

Migrate existing rows on first login with password_needs_rehash.

Nibiru doesn’t generate CSRF tokens for you. Add them yourself:

public function pageAction() {
if (!isset($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(16));
}
View::assign(['csrf' => $_SESSION['csrf']]);
}
public function submitAction() {
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(419);
return;
}
/ ...handle submission...
}

Embed <input type="hidden" name="csrf" value="{$csrf}"> in your form.

Production apps include a QR-code-based magic-link login that issues short-lived tokens. The flow:

  1. User scans QR → URL is /login/token/<one-time-token>.
  2. tokenAction validates and creates a session.

The framework already depends on bacon/bacon-qr-code and picqer/php-barcode-generator via Composer, so you can render QR codes inline:

$writer = new \BaconQrCode\Writer(new \BaconQrCode\Renderer\ImageRenderer(
new \BaconQrCode\Renderer\RendererStyle\RendererStyle(220),
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
));
$svg = $writer->writeString('https://app.example.com/login/token/' . $token);
View::assign(['qr' => $svg]);
  • $_SESSION is replaced, not merged by the default Auth::auth(). Anything you stored before login is lost. Save and restore explicitly if needed.
  • No rate limiting. Add Fail2Ban or a middleware-style observer.
  • Session cookies need flags. Set session.cookie_secure = 1, session.cookie_httponly = 1, and session.cookie_samesite = "Lax" in php.ini for production.