Add stage 1: authentication REST API
Slim 4 + SQLite todo-list API providing email/password registration, login, and an authenticated GET /me endpoint. Stateless HS256 JWTs, bcrypt password hashing, uniform JSON error envelope, and a SQL migration runner. Includes PHPUnit feature tests and stage-1 docs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Exception\ApiException;
|
||||
use App\Repository\UserRepository;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Requires a valid `Authorization: Bearer <jwt>` header. On success the resolved
|
||||
* user row is attached to the request as the `user` attribute.
|
||||
*/
|
||||
final class AuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly JwtService $jwt,
|
||||
private readonly UserRepository $users,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(Request $request, RequestHandler $handler): Response
|
||||
{
|
||||
$header = $request->getHeaderLine('Authorization');
|
||||
|
||||
if (preg_match('/^Bearer\s+(\S+)$/i', $header, $matches) !== 1) {
|
||||
throw new ApiException('Missing or malformed Authorization header.', 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$claims = $this->jwt->verify($matches[1]);
|
||||
} catch (Throwable) {
|
||||
throw new ApiException('The access token is invalid or has expired.', 401);
|
||||
}
|
||||
|
||||
$user = $this->users->findById((int) ($claims['sub'] ?? 0));
|
||||
|
||||
if ($user === null) {
|
||||
throw new ApiException('The account for this token no longer exists.', 401);
|
||||
}
|
||||
|
||||
return $handler->handle($request->withAttribute('user', $user));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
|
||||
/**
|
||||
* Issues and verifies stateless HS256 JSON Web Tokens for authenticated users.
|
||||
*/
|
||||
final class JwtService
|
||||
{
|
||||
private const ALGORITHM = 'HS256';
|
||||
|
||||
public function __construct(
|
||||
private readonly string $secret,
|
||||
private readonly int $ttl,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: int, email: string, ...} $user
|
||||
* @return array{token: string, expires_at: string}
|
||||
*/
|
||||
public function issue(array $user): array
|
||||
{
|
||||
$issuedAt = time();
|
||||
$expiresAt = $issuedAt + $this->ttl;
|
||||
|
||||
$token = JWT::encode([
|
||||
'sub' => (int) $user['id'],
|
||||
'email' => $user['email'],
|
||||
'iat' => $issuedAt,
|
||||
'exp' => $expiresAt,
|
||||
], $this->secret, self::ALGORITHM);
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'expires_at' => gmdate('c', $expiresAt),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed> The decoded claims.
|
||||
*
|
||||
* @throws \Firebase\JWT\ExpiredException
|
||||
* @throws \Firebase\JWT\SignatureInvalidException
|
||||
* @throws \UnexpectedValueException
|
||||
*/
|
||||
public function verify(string $token): array
|
||||
{
|
||||
return (array) JWT::decode($token, new Key($this->secret, self::ALGORITHM));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user