Authentication & Authorization
JWT authentication, Two-Factor Auth, RBAC, and policy-based authorization.
Table of Contents
- Overview
- Configuration
- User Entity Setup
- Registration
- Login Flow
- Protecting Routes
- Token Refresh
- Two-Factor Authentication
- Role-Based Access Control
- Policy-Based Authorization
- Logout
- Security Best Practices
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
| Feature | Description |
|---|---|
| Token Versioning | Invalidate all tokens on password change |
| Refresh Token Families | Detect refresh token reuse attacks |
| Rate Limiting | Brute-force protection on login/register |
| Account Lockout | 15-minute lockout after 5 failed attempts |
| Automatic Password Rehash | Upgrade hash algorithm without user interaction |
| TOTP 2FA | Time-based one-time passwords |
| Recovery Codes | Backup 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:
- The old refresh token is blacklisted
- A new token pair is issued with the same family ID
- If a blacklisted token is reused → entire token family is invalidated (reuse attack detected)
- 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']);
}
| Parameter | Effect |
|---|---|
allDevices: false | Blacklists only the current access token |
allDevices: true | Increments 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.