📦 Marketplace⭐ GitHub
Guidesv2.0

Authentication & Authorization

JWT authentication, Two-Factor Auth, RBAC, and policy-based authorization.


Table of Contents


Overview

MonkeysLegion uses JWT (JSON Web Tokens) for stateless API authentication:

Client                            Server
  │                                  │
  │── POST /api/auth/login ─────────▶│ Verify credentials
  │◀─── { accessToken, refreshToken }│ Issue token pair
  │                                  │
  │── GET /api/posts ───────────────▶│ Validate JWT
  │   Authorization: Bearer {token}  │
  │◀──── { data: [...] } ───────────│
  │                                  │
  │── POST /api/auth/refresh ───────▶│ Rotate tokens
  │   { refreshToken }               │
  │◀─── { accessToken, refreshToken }│ New token pair

Security Features

FeatureDescription
Token VersioningInvalidate all tokens on password change
Refresh Token FamiliesDetect refresh token reuse attacks
Rate LimitingBrute-force protection on login/register
Account Lockout15-minute lockout after 5 failed attempts
Automatic Password RehashUpgrade hash algorithm without user interaction
TOTP 2FATime-based one-time passwords
Recovery CodesBackup 2FA codes (SHA-256 hashed)

Configuration

config/auth.mlc

auth {
    guards {
        jwt {
            driver     = "jwt"
            provider   = "users"
        }
    }

    providers {
        users {
            driver = "eloquent"
            model  = "App\\Entity\\User"
        }
    }

    jwt {
        secret      = ${JWT_SECRET}
        access_ttl  = ${JWT_ACCESS_TTL:1800}      # 30 minutes
        refresh_ttl = ${JWT_REFRESH_TTL:604800}    # 7 days
        algorithm   = ${JWT_ALGORITHM:"HS256"}
        issuer      = ${JWT_ISSUER:"monkeyslegion"}
    }

    passwords {
        min_length     = 8
        require_upper  = true
        require_number = true
        require_symbol = false
    }

    rbac {
        roles {
            admin  { permissions = ["*"] }
            editor { permissions = ["posts.*", "media.*"], inherits = ["viewer"] }
            viewer { permissions = ["posts.view", "media.view"] }
        }
    }
}

.env

JWT_SECRET=your-256-bit-secret-here
JWT_ACCESS_TTL=1800
JWT_REFRESH_TTL=604800

Generate a secure secret: php -r "echo bin2hex(random_bytes(32));"


User Entity Setup

Your User entity must implement AuthenticatableInterface:

<?php
declare(strict_types=1);

namespace App\Entity;

use MonkeysLegion\Entity\Attributes\{Entity, Field, Id, Fillable, Hidden, Timestamps};
use MonkeysLegion\Auth\Contract\AuthenticatableInterface;
use MonkeysLegion\Auth\Contract\HasRolesInterface;

#[Entity(table: 'users')]
#[Timestamps]
class User implements AuthenticatableInterface, HasRolesInterface
{
    #[Id]
    #[Field(type: 'unsignedBigInt', autoIncrement: true)]
    public private(set) int $id;

    #[Field(type: 'string', length: 255)]
    #[Fillable]
    public string $email {
        set(string $value) {
            $this->email = strtolower(trim($value));
        }
    }

    #[Field(type: 'string', length: 255)]
    #[Fillable]
    public string $name;

    #[Field(type: 'string', length: 255)]
    #[Hidden]
    public string $password_hash;

    #[Field(type: 'integer', default: 1)]
    public int $token_version = 1;

    // ── AuthenticatableInterface ──

    public function getAuthIdentifier(): int|string
    {
        return $this->id;
    }

    public function getAuthIdentifierName(): string
    {
        return 'id';
    }

    public function getAuthPassword(): string
    {
        return $this->password_hash;
    }

    public function getTokenVersion(): int
    {
        return $this->token_version;
    }

    // ── HasRolesInterface ──

    protected array $roles = [];

    public function getRoles(): array
    {
        return $this->roles;
    }

    public function hasRole(string $role): bool
    {
        return in_array($role, $this->roles, true);
    }
}

Registration

Auth Controller

<?php
declare(strict_types=1);

namespace App\Controller\Api;

use MonkeysLegion\Router\Attributes\Route;
use MonkeysLegion\Router\Attributes\RoutePrefix;
use MonkeysLegion\Http\Message\Response;
use MonkeysLegion\Auth\Service\AuthService;
use Psr\Http\Message\ServerRequestInterface;

#[RoutePrefix('/api/auth')]
final class AuthController
{
    public function __construct(
        private readonly AuthService $auth,
    ) {}

    #[Route('POST', '/register', name: 'auth.register', summary: 'Register', tags: ['Auth'])]
    public function register(ServerRequestInterface $request): Response
    {
        $body = $request->getParsedBody();

        $user = $this->auth->register(
            email: $body['email'] ?? '',
            password: $body['password'] ?? '',
            attributes: [
                'name' => $body['name'] ?? '',
            ],
            ipAddress: $request->getServerParams()['REMOTE_ADDR'] ?? null,
        );

        return Response::json([
            'data' => [
                'id'    => $user->getAuthIdentifier(),
                'email' => $body['email'],
            ],
            'message' => 'Registration successful',
        ], 201);
    }
}

Login Flow

Standard Login

#[Route('POST', '/login', name: 'auth.login', summary: 'Login', tags: ['Auth'])]
public function login(ServerRequestInterface $request): Response
{
    $body = $request->getParsedBody();

    $result = $this->auth->login(
        email: $body['email'] ?? '',
        password: $body['password'] ?? '',
        ipAddress: $request->getServerParams()['REMOTE_ADDR'] ?? null,
        userAgent: $request->getHeaderLine('User-Agent'),
    );

    // 2FA Required?
    if ($result->requires2FA) {
        return Response::json([
            'requires_2fa'    => true,
            'challenge_token' => $result->challengeToken,
        ]);
    }

    // Success
    return Response::json([
        'access_token'  => $result->tokens->accessToken,
        'refresh_token' => $result->tokens->refreshToken,
        'expires_at'    => $result->tokens->accessExpiresAt,
        'token_type'    => 'Bearer',
    ]);
}

Response Examples

Success:

{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
  "expires_at": 1714200000,
  "token_type": "Bearer"
}

2FA Required:

{
  "requires_2fa": true,
  "challenge_token": "eyJhbGciOiJIUzI1NiIs..."
}

Invalid Credentials (401):

{
  "error": "Invalid credentials"
}

Account Locked (423):

{
  "error": "Account temporarily locked due to too many failed attempts.",
  "locked_until": 1714201500
}

Protecting Routes

Using #[Authenticated]

use MonkeysLegion\Auth\Attribute\Authenticated;

// Require authentication for a single method
#[Route('POST', '/posts', name: 'posts.create')]
#[Authenticated]
public function create(CreatePostRequest $dto): Response { ... }

// Require authentication for all methods in a controller
#[RoutePrefix('/api/admin')]
#[Authenticated]
final class AdminController { ... }

Accessing the Authenticated User

#[Route('GET', '/me', name: 'auth.me')]
#[Authenticated]
public function me(ServerRequestInterface $request): Response
{
    /** @var User $user */
    $user = $request->getAttribute('user');

    return Response::json([
        'id'    => $user->id,
        'email' => $user->email,
        'name'  => $user->name,
        'roles' => $user->getRoles(),
    ]);
}

The user attribute is set by the auth middleware after JWT validation.


Token Refresh

#[Route('POST', '/refresh', name: 'auth.refresh', summary: 'Refresh token', tags: ['Auth'])]
public function refresh(ServerRequestInterface $request): Response
{
    $body = $request->getParsedBody();

    $tokens = $this->auth->refresh(
        refreshToken: $body['refresh_token'] ?? '',
        ipAddress: $request->getServerParams()['REMOTE_ADDR'] ?? null,
    );

    return Response::json([
        'access_token'  => $tokens->accessToken,
        'refresh_token' => $tokens->refreshToken,
        'expires_at'    => $tokens->accessExpiresAt,
        'token_type'    => 'Bearer',
    ]);
}

Token Rotation Security

When a refresh token is used:

  1. The old refresh token is blacklisted
  2. A new token pair is issued with the same family ID
  3. If a blacklisted token is reused → entire token family is invalidated (reuse attack detected)
  4. Token version is checked against the user's current version

Two-Factor Authentication

Verify 2FA After Login

#[Route('POST', '/2fa/verify', name: 'auth.2fa.verify', summary: 'Verify 2FA', tags: ['Auth'])]
public function verify2FA(ServerRequestInterface $request): Response
{
    $body = $request->getParsedBody();

    $result = $this->auth->verify2FA(
        challengeToken: $body['challenge_token'] ?? '',
        code: $body['code'] ?? '',
        ipAddress: $request->getServerParams()['REMOTE_ADDR'] ?? null,
        userAgent: $request->getHeaderLine('User-Agent'),
    );

    return Response::json([
        'access_token'  => $result->tokens->accessToken,
        'refresh_token' => $result->tokens->refreshToken,
        'expires_at'    => $result->tokens->accessExpiresAt,
    ]);
}

Client-Side Flow

1. POST /api/auth/login        → { requires_2fa: true, challenge_token: "..." }
2. User enters TOTP code from authenticator app
3. POST /api/auth/2fa/verify   → { access_token: "...", refresh_token: "..." }

The challenge token has a 5-minute TTL. After that, the user must login again.


Role-Based Access Control

Using #[RequiresRole]

use MonkeysLegion\Auth\Attribute\RequiresRole;

#[Route('GET', '/admin/dashboard', name: 'admin.dashboard')]
#[Authenticated]
#[RequiresRole('admin')]
public function dashboard(): Response { ... }

Using #[RequiresPermission]

use MonkeysLegion\Auth\Attribute\RequiresPermission;

#[Route('PUT', '/posts/{id}', name: 'posts.update')]
#[Authenticated]
#[RequiresPermission('posts.edit')]
public function update(string $id, UpdatePostRequest $dto): Response { ... }

Checking Roles in Code

$user = $request->getAttribute('user');

if ($user->hasRole('admin')) {
    // Admin-specific logic
}

if ($user->hasPermission('posts.publish')) {
    // Can publish posts
}

// Wildcard permissions: 'posts.*' matches 'posts.edit', 'posts.delete', etc.
// Superuser: '*' matches everything

Policy-Based Authorization

Policies encapsulate authorization logic for specific entities.

Create a Policy

Create app/Policy/ProductPolicy.php:

<?php
declare(strict_types=1);

namespace App\Policy;

use App\Entity\Product;
use App\Entity\User;

final class ProductPolicy
{
    public function view(User $user, Product $product): bool
    {
        return true; // Everyone can view
    }

    public function update(User $user, Product $product): bool
    {
        return $user->hasRole('admin') || $user->hasRole('editor');
    }

    public function delete(User $user, Product $product): bool
    {
        return $user->hasRole('admin');
    }
}

Use in Controller

use MonkeysLegion\Auth\Attribute\Authorize;

#[Route('PUT', '/{id:\d+}', name: 'products.update')]
#[Authenticated]
#[Authorize(ProductPolicy::class, 'update')]
public function update(string $id, UpdateProductRequest $dto): Response { ... }

Logout

#[Route('POST', '/logout', name: 'auth.logout', summary: 'Logout', tags: ['Auth'])]
#[Authenticated]
public function logout(ServerRequestInterface $request): Response
{
    $token = str_replace('Bearer ', '', $request->getHeaderLine('Authorization'));
    $body = $request->getParsedBody();
    $allDevices = (bool) ($body['all_devices'] ?? false);

    $this->auth->logout(
        accessToken: $token,
        allDevices: $allDevices,
        ipAddress: $request->getServerParams()['REMOTE_ADDR'] ?? null,
    );

    return Response::json(['message' => 'Logged out']);
}
ParameterEffect
allDevices: falseBlacklists only the current access token
allDevices: trueIncrements token version → invalidates ALL sessions

Security Best Practices

1. Token Storage (Client-Side)

✅ HttpOnly cookies (for web apps)
✅ Secure storage (mobile apps)
❌ localStorage (vulnerable to XSS)
❌ URL parameters

2. HTTPS Always

APP_URL=https://api.example.com

3. Short-Lived Access Tokens

JWT_ACCESS_TTL=1800    # 30 minutes max
JWT_REFRESH_TTL=604800 # 7 days

4. Rate Limiting

Built-in: 5 login attempts → 15-minute lockout. Configure in AuthService:

private const int MAX_LOGIN_ATTEMPTS = 5;
private const int LOCKOUT_SECONDS    = 900;  // 15 minutes

5. Password Hashing

MonkeysLegion uses bcrypt by default with automatic rehashing when the cost factor changes.

6. CSRF Protection

For session-based web views:

<form method="POST" action="/posts">
    <input type="hidden" name="_token" value="{{ $session->token() }}">
    <!-- form fields -->
</form>

The VerifyCsrfToken middleware validates automatically.