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.
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. |
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.
Do not persist that ciphertext in the database expecting to decrypt it in a later session.
<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;
});
}, { 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.
<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>
<?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']];
}
$login = Auth::login(['email' => $email, 'password' => $password, 'stage' => 0], $remember);
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") and parseReply — same boot as the forms article.
Protect staff pages with middleware that returns a Response when !Auth::isLogged() or !Auth::can(...).
FAQ
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.