Refactoring, regression testing until Phase 1 end.

This commit is contained in:
2026-02-18 20:48:25 -05:00
parent 5422a324fc
commit 249e0ade8e
26 changed files with 1639 additions and 253 deletions

View File

@@ -4,10 +4,10 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\JwtService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Validator;
/**
@@ -17,6 +17,19 @@ use Illuminate\Support\Facades\Validator;
*/
class AuthController extends Controller
{
/**
* JWT Service instance
*/
protected JwtService $jwtService;
/**
* Constructor
*/
public function __construct(JwtService $jwtService)
{
$this->jwtService = $jwtService;
}
/**
* Login and get tokens
*
@@ -50,6 +63,7 @@ class AuthController extends Controller
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors(),
], 422);
}
@@ -68,14 +82,14 @@ class AuthController extends Controller
], 403);
}
$accessToken = $this->generateAccessToken($user);
$refreshToken = $this->generateRefreshToken($user);
$accessToken = $this->jwtService->generateAccessToken($user);
$refreshToken = $this->jwtService->generateRefreshToken($user);
return response()->json([
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => 'bearer',
'expires_in' => 3600,
'expires_in' => $this->jwtService->getAccessTokenTTL(),
'user' => [
'id' => $user->id,
'name' => $user->name,
@@ -105,7 +119,13 @@ class AuthController extends Controller
{
$refreshToken = $request->input('refresh_token');
$userId = $this->getUserIdFromRefreshToken($refreshToken);
if (empty($refreshToken)) {
return response()->json([
'message' => 'Refresh token is required',
], 422);
}
$userId = $this->jwtService->getUserIdFromRefreshToken($refreshToken);
if (! $userId) {
return response()->json([
@@ -121,16 +141,16 @@ class AuthController extends Controller
], 401);
}
$this->invalidateRefreshToken($refreshToken, $userId);
$this->jwtService->invalidateRefreshToken($refreshToken, $userId);
$accessToken = $this->generateAccessToken($user);
$newRefreshToken = $this->generateRefreshToken($user);
$accessToken = $this->jwtService->generateAccessToken($user);
$newRefreshToken = $this->jwtService->generateRefreshToken($user);
return response()->json([
'access_token' => $accessToken,
'refresh_token' => $newRefreshToken,
'token_type' => 'bearer',
'expires_in' => 3600,
'expires_in' => $this->jwtService->getAccessTokenTTL(),
]);
}
@@ -150,99 +170,11 @@ class AuthController extends Controller
$refreshToken = $request->input('refresh_token');
if ($refreshToken) {
$this->invalidateRefreshToken($refreshToken, $user->id);
$this->jwtService->invalidateRefreshToken($refreshToken, $user?->id);
}
return response()->json([
'message' => 'Logged out successfully',
]);
}
protected function generateAccessToken(User $user): string
{
$payload = [
'iss' => config('app.url', 'headroom'),
'sub' => $user->id,
'iat' => time(),
'exp' => time() + 3600,
'role' => $user->role,
'permissions' => $this->getPermissions($user->role),
'jti' => uniqid('token_', true),
];
return $this->encodeJWT($payload);
}
protected function generateRefreshToken(User $user): string
{
$token = bin2hex(random_bytes(32));
// Store with token as the key part for easy lookup
$key = "refresh_token:{$token}";
Redis::setex($key, 604800, $user->id);
return $token;
}
protected function getUserIdFromRefreshToken(string $token): ?string
{
return Redis::get("refresh_token:{$token}") ?: null;
}
protected function invalidateRefreshToken(string $token, string $userId): void
{
Redis::del("refresh_token:{$token}");
}
protected function getPermissions(string $role): array
{
return match ($role) {
'superuser' => [
'manage_users',
'manage_team_members',
'manage_projects',
'manage_allocations',
'manage_actuals',
'view_reports',
'configure_system',
'view_audit_logs',
],
'manager' => [
'manage_projects',
'manage_allocations',
'manage_actuals',
'view_reports',
'manage_team_members',
],
'developer' => [
'manage_actuals',
'view_own_allocations',
'view_own_actuals',
'log_hours',
],
'top_brass' => [
'view_reports',
'view_allocations',
'view_actuals',
'view_capacity',
],
default => [],
};
}
protected function encodeJWT(array $payload): string
{
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$header = base64_encode($header);
$header = str_replace(['+', '/', '='], ['-', '_', ''], $header);
$payload = json_encode($payload);
$payload = base64_encode($payload);
$payload = str_replace(['+', '/', '='], ['-', '_', ''], $payload);
$signature = hash_hmac('sha256', $header . '.' . $payload, config('app.key'), true);
$signature = base64_encode($signature);
$signature = str_replace(['+', '/', '='], ['-', '_', ''], $signature);
return $header . '.' . $payload . '.' . $signature;
}
}

View File

@@ -0,0 +1,283 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
/**
* JWT Service
*
* Handles JWT token generation, validation, and refresh token management.
*/
class JwtService
{
/**
* Access token TTL in seconds (60 minutes)
*/
private const ACCESS_TOKEN_TTL = 3600;
/**
* Refresh token TTL in seconds (7 days)
*/
private const REFRESH_TOKEN_TTL = 604800;
/**
* Generate a new access token for a user
*
* @param User $user
* @return string
*/
public function generateAccessToken(User $user): string
{
$payload = [
'iss' => config('app.url', 'headroom'),
'sub' => $user->id,
'iat' => time(),
'exp' => time() + self::ACCESS_TOKEN_TTL,
'role' => $user->role,
'permissions' => $this->getPermissions($user->role),
'jti' => $this->generateTokenId(),
];
return $this->encodeJWT($payload);
}
/**
* Generate a new refresh token for a user
*
* @param User $user
* @return string
*/
public function generateRefreshToken(User $user): string
{
$token = $this->generateSecureToken();
$key = $this->getRefreshTokenKey($token);
Cache::put($key, $user->id, self::REFRESH_TOKEN_TTL);
return $token;
}
/**
* Get user ID from a refresh token
*
* @param string $token
* @return string|null
*/
public function getUserIdFromRefreshToken(string $token): ?string
{
return Cache::get($this->getRefreshTokenKey($token));
}
/**
* Invalidate a refresh token
*
* @param string $token
* @param string|null $userId
* @return void
*/
public function invalidateRefreshToken(string $token, ?string $userId = null): void
{
Cache::forget($this->getRefreshTokenKey($token));
}
/**
* Validate and decode a JWT token
*
* @param string $token
* @return array|null Returns payload array or null if invalid
*/
public function validateToken(string $token): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$header, $payload, $signature] = $parts;
// Verify signature
$expectedSignature = $this->createSignature($header, $payload);
if (! hash_equals($expectedSignature, $signature)) {
return null;
}
// Decode payload
$payloadData = $this->base64UrlDecode($payload);
$payloadArray = json_decode($payloadData, true);
if (! is_array($payloadArray)) {
return null;
}
// Check expiration
if (isset($payloadArray['exp']) && $payloadArray['exp'] < time()) {
return null;
}
return $payloadArray;
}
/**
* Extract claims from a JWT token
*
* @param string $token
* @return array|null
*/
public function extractClaims(string $token): ?array
{
return $this->validateToken($token);
}
/**
* Get token expiration time
*
* @return int
*/
public function getAccessTokenTTL(): int
{
return self::ACCESS_TOKEN_TTL;
}
/**
* Get refresh token expiration time
*
* @return int
*/
public function getRefreshTokenTTL(): int
{
return self::REFRESH_TOKEN_TTL;
}
/**
* Get permissions for a role
*
* @param string $role
* @return array
*/
public function getPermissions(string $role): array
{
return match ($role) {
'superuser' => [
'manage_users',
'manage_team_members',
'manage_projects',
'manage_allocations',
'manage_actuals',
'view_reports',
'configure_system',
'view_audit_logs',
],
'manager' => [
'manage_projects',
'manage_allocations',
'manage_actuals',
'view_reports',
'manage_team_members',
],
'developer' => [
'manage_actuals',
'view_own_allocations',
'view_own_actuals',
'log_hours',
],
'top_brass' => [
'view_reports',
'view_allocations',
'view_actuals',
'view_capacity',
],
default => [],
};
}
/**
* Encode a JWT token
*
* @param array $payload
* @return string
*/
private function encodeJWT(array $payload): string
{
$header = $this->base64UrlEncode(json_encode(['typ' => 'JWT', 'alg' => 'HS256']));
$payload = $this->base64UrlEncode(json_encode($payload));
$signature = $this->createSignature($header, $payload);
return $header . '.' . $payload . '.' . $signature;
}
/**
* Create a signature for JWT
*
* @param string $header
* @param string $payload
* @return string
*/
private function createSignature(string $header, string $payload): string
{
$signature = hash_hmac('sha256', $header . '.' . $payload, config('app.key'), true);
return $this->base64UrlEncode($signature);
}
/**
* Generate a cryptographically secure random token
*
* @return string
*/
private function generateSecureToken(): string
{
return bin2hex(random_bytes(32));
}
/**
* Generate a unique token ID
*
* @return string
*/
private function generateTokenId(): string
{
return uniqid('token_', true);
}
/**
* Get cache key for refresh token
*
* @param string $token
* @return string
*/
private function getRefreshTokenKey(string $token): string
{
return "refresh_token:{$token}";
}
/**
* Base64URL encode
*
* @param string $data
* @return string
*/
private function base64UrlEncode(string $data): string
{
return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
}
/**
* Base64URL decode
*
* @param string $data
* @return string
*/
private function base64UrlDecode(string $data): string
{
$padding = 4 - (strlen($data) % 4);
if ($padding !== 4) {
$data .= str_repeat('=', $padding);
}
return base64_decode(str_replace(['-', '_'], ['+', '/'], $data));
}
}