Files
ohmj2/api/lib/Auth.php
T

132 lines
4.1 KiB
PHP

<?php
class Auth {
private const JWT_ALGO = 'HS256';
private const JWT_EXPIRY = 3600; // 1 hour
private string $usersFile;
private string $jwtSecret;
public function __construct(?string $usersFile = null) {
$this->usersFile = $usersFile ?? __DIR__ . '/../config/users.json';
// Load JWT secret from environment variable
$this->jwtSecret = $_ENV['JWT_SECRET'] ?? getenv('JWT_SECRET');
if (empty($this->jwtSecret)) {
throw new Exception('JWT_SECRET environment variable is not configured');
}
}
public function login(string $username, string $password): array {
$users = $this->loadUsers();
$user = null;
foreach ($users['users'] as $candidate) {
if (hash_equals($candidate['username'], $username)) {
$user = $candidate;
break;
}
}
// Always run a password check to avoid a timing oracle on user existence
if ($user === null) {
password_verify($password, '$2y$10$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG');
return ['success' => false, 'error' => 'Invalid credentials'];
}
if (!password_verify($password, $user['password'])) {
return ['success' => false, 'error' => 'Invalid credentials'];
}
$token = $this->generateToken($user);
return [
'success' => true,
'token' => $token,
'user' => [
'username' => $user['username'],
'role' => $user['role']
]
];
}
public function verifyToken(string $token): ?array {
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
list($header, $payload, $signature) = $parts;
// Verify algorithm header before trusting anything
$headerData = json_decode(self::base64UrlDecode($header), true);
if (!is_array($headerData) || ($headerData['alg'] ?? '') !== self::JWT_ALGO) {
return null;
}
// Verify signature (constant-time)
$expectedSignature = self::base64UrlEncode(
hash_hmac('sha256', "$header.$payload", $this->jwtSecret, true)
);
if (!hash_equals($expectedSignature, $signature)) {
return null;
}
// Decode payload
$payloadData = json_decode(self::base64UrlDecode($payload), true);
if (!is_array($payloadData)) {
return null;
}
// Expiry is mandatory
if (!isset($payloadData['exp']) || !is_numeric($payloadData['exp']) || (int) $payloadData['exp'] < time()) {
return null;
}
return $payloadData;
}
private static function base64UrlEncode(string $data): string {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $data): string {
$remainder = strlen($data) % 4;
if ($remainder !== 0) {
$data .= str_repeat('=', 4 - $remainder);
}
return base64_decode(strtr($data, '-_', '+/'));
}
private function generateToken(array $user): string {
$header = self::base64UrlEncode(json_encode([
'alg' => self::JWT_ALGO,
'typ' => 'JWT'
]));
$payload = self::base64UrlEncode(json_encode([
'username' => $user['username'],
'role' => $user['role'],
'iat' => time(),
'exp' => time() + self::JWT_EXPIRY
]));
$signature = self::base64UrlEncode(
hash_hmac('sha256', "$header.$payload", $this->jwtSecret, true)
);
return "$header.$payload.$signature";
}
private function loadUsers(): array {
if (!file_exists($this->usersFile)) {
return ['users' => []];
}
$content = file_get_contents($this->usersFile);
return json_decode($content, true) ?? ['users' => []];
}
}