Skip to content

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.

See also