AI blog · DotApp PHP Framework 2.0
How authentication and 2FA work in DotApp PHP Framework
Auth::login() returns false on malformed input — check that before you read array keys.
A successful call returns logged, error, and error_txt.
Auth::isLogged() is the session test. Auth::logged() does not exist: calling it throws \BadMethodCallException.
Stage 2 means the password was accepted and the user must confirm 2FA.
This article is a Shop login with <fo-rm>, crcCheck, TOTP, and $dotapp().twoFactor.
Passwords and other secrets come from $request->data(true) — see Request data.
Canonical map: Request lifecycle in DotApp PHP Framework.
Request data: protected vs original
Incoming GET/POST is auto-protected so the programmer is not one forgotten sanitizer away from XSS / injection-era bugs.
Request has an explicit switch:
$request->data() (or data(false)) is the protected, escaped copy — safe to print into HTML.
$request->data(true) is the original array as the client sent it.
After a secure-channel unwrap, fields live under $request->data(true)['data'].
Passwords, decrypt, hashes, CRC payloads, and any string you compare or store as-is must use original data.
Characters such as ), =, %, quotes, and & are rewritten in the protected copy.
Hashing that copy is hashing a different password — login fails even when the typed password was correct.
DotApp::DotApp()->unprotect($variable) still exists for a value you already hold (by reference). Prefer data(true) on the request.
Same contract: How to create secure forms.
Dedicated article: Request lifecycle.
Common mistakes
| Wrong | Right |
|---|---|
$r['logged'] without $r === false first |
Malformed input is false, not an array. Check that, then keys. |
Auth::logged() |
The method does not exist and throws. Use Auth::isLogged(). |
| Treat stage 2 as fully signed in | isLogged() is false until 2FA confirms. Stage 2 is waiting. |
| Put raw user ids in HTML or JSON for the browser | Encrypt with a unique context key per field, then still call Auth::can(). |
| Invent digit boxes for TOTP | $dotapp(".two-fa-inputs input").twoFactor(...) — already in dotapp.js. |
Skip crcCheck on the login POST |
Browser posts through <fo-rm> / load() still verify CRC. |
$request->data()['data']['password'] into Auth::login |
$request->data(true)['data']['password']. Special characters in the protected copy are a different string. |
Only .after() on the login form; HTTP 400 shows a blank page |
crcCheck / rejected envelope is HTTP 400/403 and fires .onError(). parseReply that body too, then unstick the button. |
Let Auth::login throw with no try/catch |
Wrap it. Return HTTP 200 with status 0 and a message. Log the exception. |
Login return shape
use Dotsystems\App\Parts\Auth;
$result = Auth::login([
'email' => $email, // OR 'username' => $u — never both
'password' => $password, // OR 'passwordHash'
'stage' => 0,
], $rememberMe = false);
if ($result === false) {
return ['status' => 0, 'message' => 'Bad request'];
}
if ($result['logged'] === true) {
if (Auth::loggedStage() === 2) {
// awaiting 2FA
}
} else {
// $result['error'] / $result['error_txt']
}
Array keys: logged (bool), error (int), error_txt (string|null).
error |
Meaning |
|---|---|
| 0 | No error |
| 1 | IP blocked by the per-user firewall |
| 2 | Wrong password |
| 3 | User not found |
| 4 | Failed loading the rights list |
| 5 | Both email and username supplied |
| 99 | Database error |
Codes 2 and 3 should share one public message (“Invalid email or password”) so the client cannot tell them apart. Log the numeric code on the server.
Session flags
| Call | Returns |
|---|---|
Auth::isLogged() |
bool — stage 1 and logged |
Auth::loggedStage() |
0 none, 1 full, 2 awaiting 2FA |
Also: userId(), username(), permissions() (array of "Module.right"), logout($clearSessionCookie = false).
Auth::logged() is listed on the facade but is not implemented. Calling it throws \BadMethodCallException. Always use isLogged().
Do not use Auth::hasRole() — core never populates roles. Permissions are the rights check.
if (!Auth::can(['dotapp.root', 'Shop.admin'])) {
return new Response(403, 'Forbidden');
}
if (!Auth::can(['Shop.read', 'Shop.write'], \Dotsystems\App\Parts\AuthObj::$And)) {
// both required
}
Default can() is OR. Pass AuthObj::$And when every listed right is required.
A non-string / non-array argument returns false.
Identifiers in the browser
Never send a raw user id (or any primary key) the browser can copy onto another field.
Encrypt with a unique context string per field — Shop.user.id is not Shop.item.id.
Decrypt returns false on failure. After a successful decrypt, still call Auth::can() (and ownership checks). Transport checks are not authorization.
Extra keys are one layer of the working key (with the per-session key, app.c_enc_key, and key update).
Do not persist that ciphertext in the database expecting to decrypt it in a later session.
The stack: How DotApp PHP Framework protects the browser-to-PHP channel.
<input type="hidden" name="userid" value="{{ enc(Shop.user.id): $userId }}" />
$id = Crypto::decrypt($payload['userid'] ?? '', 'Shop.user.id');
if ($id === false) {
return Response::json(['status' => 0, 'message' => 'Invalid token'], 400);
}
if (!Auth::can(['Shop.admin'])) {
return new Response(403, 'Forbidden');
}
TOTP and $dotapp().twoFactor
App 2FA uses a Base32 secret on the user row. Enrolment is TOTP::newSecret(), TOTP::otpauth($email, $secret) for a QR, then persist the secret and set the enable flag.
Confirmation is Auth::confirmTwoFactor(['tfa' => $code]) while loggedStage() === 2.
SMS and email codes are generated by core but not sent — your module delivers them.
Return keys: confirmed (bool), error (int), error_txt.
error: 0 confirmed (stage becomes 1); 1 not in stage 2; 2 invalid TOTP; 3 invalid SMS; 4 invalid email; 5 no recognised method.
Completing the boxes in the browser does not authorize — PHP must call confirmTwoFactor before you treat the session as stage 1.
Remember-me login skips the 2FA stage. Do not enable automatic remember-me on surfaces that rely on 2FA.
Markup: a .two-fa-inputs wrapper with six <input maxlength="1" inputmode="numeric" autocomplete="one-time-code"> fields.
$dotapp(".two-fa-inputs input").twoFactor(function (code) {
$dotapp().load("/shop/login/2fa", "POST", { tfa: code }, function (raw) {
var reply = $dotapp().parseReply(raw);
if (reply && reply.status == 1 && reply.redirectTo) window.location = reply.redirectTo;
else if (reply && reply.message) $dotapp("#error-message").attr("hide", "false").html(reply.message);
}, function (status, errorText) {
var reply = $dotapp().parseReply(errorText);
var msg = (reply && typeof reply === "object" && reply.message) ? reply.message : "Verification failed";
$dotapp("#error-message").attr("hide", "false").html(msg);
});
}, { length: 6, allowLetters: false, autoSubmit: true });
Complete login controller
Shorter than the full forms walkthrough — the same <fo-rm> + formName + crcCheck + form() contract.
Details: How to create secure forms in DotApp PHP Framework.
<div id="error-message" hide="hide"></div>
<fo-rm method="POST" id="loginForm" action="{{ var: $postAction }}">
<input type="email" name="email" autocomplete="username" required />
<input type="password" name="password" autocomplete="current-password" required />
<label><input type="checkbox" name="remember" /> {{_ "Remember me" }}</label>
{{ formName(loginForm) }}
<button type="submit" id="loginBtn">{{_ "Sign in" }}</button>
</fo-rm>
<script src="/assets/dotapp/dotapp.js"></script>
HTTP 400/403 from crcCheck or a rejected envelope never reaches .after() — only .onError().
If you skip onError, the button stays loading and the page stays blank even though the body still contains ajaxReply (“Bad request”).
Always parseReply the error body too, then unstick loaders.
Application errors (wrong password, validation) should stay HTTP 200 with status 0 so .after() can show them.
(function () {
var runMe = function ($dotapp) {
var messageFrom = function (raw, fallback) {
var reply = $dotapp().parseReply(raw);
if (reply && typeof reply === "object" && reply.message) return reply.message;
return fallback;
};
var finish = function (raw, form, fallback) {
var reply = $dotapp().parseReply(raw);
if (reply && typeof reply === "object" && reply.status == 1 && reply.redirectTo) {
window.location = reply.redirectTo;
return;
}
$dotapp("#error-message").attr("hide", "false").html(messageFrom(raw, fallback));
$dotapp(form).attr("blocked", "0");
$dotapp("#loginBtn").removeAttr("loading").removeAttr("loader");
};
$dotapp()
.form("#loginForm")
.before(function (data, form) {
if ($dotapp(form).attr("blocked") == 1) return $dotapp().halt();
$dotapp(form).attr("blocked", "1");
$dotapp("#loginBtn").attr("loading", "true").attr("loader", "dots");
$dotapp("#error-message").attr("hide", "hide");
})
.after(function (data, response, form) {
finish(response, form, "Sign-in failed");
})
.onError(function (data, status, error, form) {
finish(error, form, "Sign-in failed");
});
};
if (window.$dotapp) runMe(window.$dotapp);
else window.addEventListener("dotapp", function () { runMe(window.$dotapp); }, { once: true });
})();
<?php
namespace Dotsystems\App\Modules\Shop\Controllers;
use Dotsystems\App\DotApp;
use Dotsystems\App\Parts\Auth;
use Dotsystems\App\Parts\Logger;
use Dotsystems\App\Parts\Renderer;
use Dotsystems\App\Parts\Response;
use Dotsystems\App\Parts\Validator;
class Login extends \Dotsystems\App\Parts\Controller
{
public static function page($request)
{
if (Auth::isLogged()) {
return Response::redirect('/shop/', 302);
}
$html = Renderer::new()->module('Shop')->setView('login')
->setViewVar('title', 'Sign in')->setViewVar('postAction', '/shop/login')->renderView();
return $html === '' ? new Response(500, 'Template error') : $html;
}
public static function loginPost($request)
{
if (Auth::isLogged() || Auth::loggedStage() === 2) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Already signed in'], 200);
}
if (!$request->crcCheck()) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Bad request'], 400);
}
$answer = $request->form(['POST'], 'loginForm', function ($request) {
$payload = $request->data(true)['data'] ?? [];
$email = trim((string) ($payload['email'] ?? ''));
$password = (string) ($payload['password'] ?? '');
$remember = (($payload['remember'] ?? '') === 'on');
if (!Validator::isEmail($email)) {
return ['code' => 200, 'body' => ['status' => 0, 'message' => 'Enter a valid email']];
}
try {
$login = Auth::login(['email' => $email, 'password' => $password, 'stage' => 0], $remember);
} catch (\Throwable $e) {
Logger::use()->error('Shop login exception', ['msg' => $e->getMessage()]);
return ['code' => 200, 'body' => ['status' => 0, 'message' => 'Sign-in is unavailable']];
}
if ($login === false) {
return ['code' => 200, 'body' => ['status' => 0, 'message' => 'Bad request']];
}
if ($login['logged'] !== true) {
$map = [
1 => 'Access blocked from your IP',
2 => 'Invalid email or password',
3 => 'Invalid email or password',
4 => 'Could not load permissions',
5 => 'Bad request',
99 => 'Server error',
];
Logger::use()->warning('login failed', ['error' => $login['error']]);
return ['code' => 200, 'body' => [
'status' => 0, 'message' => $map[$login['error']] ?? 'Login failed',
]];
}
if (Auth::loggedStage() === 2) {
return ['code' => 200, 'body' => [
'status' => 1, 'twofactor' => 1, 'redirectTo' => '/shop/login/2fa',
]];
}
return ['code' => 200, 'body' => ['status' => 1, 'redirectTo' => '/shop/']];
}, function ($request, $name) {
return ['code' => 403, 'body' => ['status' => 0, 'message' => 'Invalid signature']];
});
if (!is_array($answer) || !isset($answer['body'])) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Rejected'], 400);
}
return DotApp::DotApp()->ajaxReply($answer['body'], $answer['code']);
}
public static function confirmPost($request)
{
if (!$request->crcCheck()) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Bad request'], 400);
}
$code = (string) ($request->data(true)['data']['tfa'] ?? '');
$r = Auth::confirmTwoFactor(['tfa' => $code]);
if ($r['confirmed'] !== true) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Verification failed'], 200);
}
return DotApp::DotApp()->ajaxReply(['status' => 1, 'redirectTo' => '/shop/'], 200);
}
}
Wire Router::post('/shop/login', 'Shop:Login@loginPost!', Router::STATIC_ROUTE) and throttle it.
Hook the form with $dotapp().form("#loginForm"), parseReply, and .onError() — same boot as the forms article.
Protect staff pages with middleware that returns a Response when !Auth::isLogged() or !Auth::can(...).
FAQ
Why did “Bad request” show nothing on the page?
crcCheck() failure (and a rejected envelope) is HTTP 400. $dotapp().load() then calls the error callback, not .after().
Without .onError() the loader stays on and #error-message stays hidden — even though ajaxReply still carried message.
parseReply that error body. Wrong password is HTTP 200 with status 0 and does go through .after().
Do special characters in the password break login?
Not if you read original data. data() rewrites characters such as ), =, %, quotes, and &.
Auth::login must receive $request->data(true)['data']['password'].
Installer / CLI Auth::createUser hashes whatever string actually arrived (Request protect does not run there).
If you passed the password as a shell argument, the shell may eat those characters — type it in the installer’s own prompt.
Why did login return false?
Missing password, both email and username, or a wrong stage. That is not error code 5 inside an array — it is the boolean false. Reading $login['error'] on false is a PHP error.
Is stage 2 logged in?
Password matched. 2FA is still required. isLogged() stays false until confirmTwoFactor succeeds and stage becomes 1.
How do I create a user?
Auth::createUser($username, $password, $email, $attrs) returns error 0, 1 (duplicate), or 99 (database).
Invalid email throws — wrap in try/catch. Passwords are hashed inside the call. Core has no password-reset flow; build that in the module if you need it.
Does remember-me ask for 2FA?
No. The remember-me path skips stage 2. Leave automatic remember-me off when 2FA is required.
App state belongs in DSM::use('Shop'), not a raw PHP session array.
Should I SELECT from the users table myself?
Login and 2FA go through Auth::. If you look up a core user row, use Config::db('prefix') plus named RAW
(WHERE email = :email and ['email' => $email]) — never concatenate the address into SQL.
Module data stays in shop_*. Details: How to use the database in DotApp PHP Framework.