archetecture security fix

This commit is contained in:
root
2026-06-11 03:22:12 -04:00
parent 6def9993da
commit 9483750161
3126 changed files with 177194 additions and 37211 deletions
@@ -8,6 +8,7 @@ use App\Services\Auth\ApiLoginSecurityService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
class AuthController extends BaseApiController
@@ -24,6 +25,11 @@ class AuthController extends BaseApiController
$password = (string) ($payload['password'] ?? '');
if ($email === '' || $password === '') {
Log::notice('api_login: missing credentials', [
'ip' => (string) $request->ip(),
'email' => $this->maskEmail($email),
]);
return response()->json([
'status' => false,
'message' => 'Email and password are required.',
@@ -32,6 +38,11 @@ class AuthController extends BaseApiController
$ip = $request->ip();
if ($security->isIpBlocked((string) $ip)) {
Log::notice('api_login: ip blocked', [
'ip' => (string) $ip,
'email' => $this->maskEmail($email),
]);
return response()->json([
'status' => false,
'message' => 'Too many failed attempts from your IP. Please try again later.',
@@ -41,6 +52,24 @@ class AuthController extends BaseApiController
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
if (!$user) {
$security->logIpAttempt((string) $ip);
Log::notice('api_login: unknown email', [
'ip' => (string) $ip,
'email' => $this->maskEmail($email),
]);
return response()->json([
'status' => false,
'message' => 'Invalid email or password.',
], 401);
}
if (blank($user->password)) {
$security->logIpAttempt((string) $ip);
Log::notice('api_login: password missing', [
'ip' => (string) $ip,
'email' => $this->maskEmail($email),
'user_id' => (int) $user->id,
]);
return response()->json([
'status' => false,
@@ -50,6 +79,12 @@ class AuthController extends BaseApiController
if (! verify_stored_password($password, (string) $user->password)) {
$security->handleFailedLogin($user, $email, (string) $ip);
Log::notice('api_login: password mismatch', [
'ip' => (string) $ip,
'email' => $this->maskEmail($email),
'user_id' => (int) $user->id,
'failed_attempts' => (int) ($user->fresh()?->failed_attempts ?? 0),
]);
return response()->json([
'status' => false,
@@ -60,6 +95,12 @@ class AuthController extends BaseApiController
// Suspension is checked AFTER credentials are verified so that the
// response shape cannot be used to enumerate which emails exist.
if ($user->is_suspended) {
Log::notice('api_login: suspended account', [
'ip' => (string) $ip,
'email' => $this->maskEmail($email),
'user_id' => (int) $user->id,
]);
return response()->json([
'status' => false,
'message' => 'Account suspended. Please reset your password.',
@@ -71,6 +112,12 @@ class AuthController extends BaseApiController
$fresh = $user->fresh();
if (!$fresh) {
Log::warning('api_login: fresh user reload failed after successful verification', [
'ip' => (string) $ip,
'email' => $this->maskEmail($email),
'user_id' => (int) $user->id,
]);
return response()->json([
'status' => false,
'message' => 'Invalid email or password.',
@@ -82,6 +129,19 @@ class AuthController extends BaseApiController
return response()->json($security->buildLoginResponse($fresh, $jwtToken));
}
private function maskEmail(string $email): string
{
$email = trim(strtolower($email));
if ($email === '' || ! str_contains($email, '@')) {
return $email;
}
[$local, $domain] = explode('@', $email, 2);
$localPrefix = substr($local, 0, min(2, strlen($local)));
return $localPrefix . str_repeat('*', max(strlen($local) - strlen($localPrefix), 0)) . '@' . $domain;
}
public function refresh(Request $request): JsonResponse
{
try {
@@ -12,6 +12,7 @@ use App\Services\Auth\AuthSessionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
/**
@@ -89,6 +90,11 @@ class AuthSessionController extends Controller
$ip = (string) $request->ip();
if ($this->security->isIpBlocked($ip)) {
Log::notice('session_login: ip blocked', [
'ip' => $ip,
'email' => $this->maskEmail($email),
]);
return response()->json([
'status' => false,
'message' => 'Too many failed attempts from your IP. Please try again later.',
@@ -99,6 +105,24 @@ class AuthSessionController extends Controller
if (! $user) {
$this->security->logIpAttempt($ip);
Log::notice('session_login: unknown email', [
'ip' => $ip,
'email' => $this->maskEmail($email),
]);
return response()->json([
'status' => false,
'message' => 'The email and password combination you entered is invalid. Please try again.',
], 401);
}
if (blank($user->password)) {
$this->security->logIpAttempt($ip);
Log::notice('session_login: password missing', [
'ip' => $ip,
'email' => $this->maskEmail($email),
'user_id' => (int) $user->id,
]);
return response()->json([
'status' => false,
@@ -107,6 +131,12 @@ class AuthSessionController extends Controller
}
if ($user->is_suspended) {
Log::notice('session_login: suspended account', [
'ip' => $ip,
'email' => $this->maskEmail($email),
'user_id' => (int) $user->id,
]);
return response()->json([
'status' => false,
'message' => 'Account suspended. Please check your email to reset your password.',
@@ -115,6 +145,12 @@ class AuthSessionController extends Controller
if (! verify_stored_password($password, (string) $user->password)) {
$this->security->handleFailedLogin($user, $email, $ip);
Log::notice('session_login: password mismatch', [
'ip' => $ip,
'email' => $this->maskEmail($email),
'user_id' => (int) $user->id,
'failed_attempts' => (int) ($user->fresh()?->failed_attempts ?? 0),
]);
return response()->json([
'status' => false,
@@ -124,6 +160,12 @@ class AuthSessionController extends Controller
$fresh = $user->fresh();
if (! $fresh) {
Log::warning('session_login: fresh user reload failed after successful verification', [
'ip' => $ip,
'email' => $this->maskEmail($email),
'user_id' => (int) $user->id,
]);
return response()->json([
'status' => false,
'message' => 'The email and password combination you entered is invalid. Please try again.',
@@ -151,6 +193,19 @@ class AuthSessionController extends Controller
]));
}
private function maskEmail(string $email): string
{
$email = trim(strtolower($email));
if ($email === '' || ! str_contains($email, '@')) {
return $email;
}
[$local, $domain] = explode('@', $email, 2);
$localPrefix = substr($local, 0, min(2, strlen($local)));
return $localPrefix . str_repeat('*', max(strlen($local) - strlen($localPrefix), 0)) . '@' . $domain;
}
/** Closes the current web session. */
public function logout(Request $request): JsonResponse
{
@@ -0,0 +1,402 @@
<?php
namespace App\Http\Controllers\Api\CompetitionWinners;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Admin\SaveCompetitionScoresRequest;
use App\Http\Requests\Admin\StoreCompetitionWinnerRequest;
use App\Http\Requests\Admin\UpdateCompetitionWinnerRequest;
use App\Services\Admin\CompetitionWinners\CompetitionWinnersAdminService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CompetitionWinnersController extends BaseApiController
{
public function __construct(
private CompetitionWinnersAdminService $service,
) {
parent::__construct();
}
public function index(): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $this->service->indexData();
return response()->json([
'ok' => true,
'competitions' => array_values(array_map(
fn ($row) => $this->normalizeCompetitionRow($this->rowToArray($row)),
$this->iterableToArray($payload['competitions'] ?? [])
)),
'sectionMap' => $payload['sectionMap'] ?? [],
]);
}
public function createForm(): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
return response()->json([
'ok' => true,
...$this->service->createFormData(),
]);
}
public function settingsForm(int $id): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $this->service->settingsFormData($id);
if ($payload === null) {
return response()->json([
'ok' => false,
'message' => 'Competition not found.',
], Response::HTTP_NOT_FOUND);
}
return response()->json([
'ok' => true,
...$payload,
]);
}
public function store(StoreCompetitionWinnerRequest $request): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validated = $request->validated();
$id = $this->service->storeCompetition(
$validated,
(array) ($validated['winner_overrides'] ?? []),
(array) ($validated['question_counts'] ?? []),
(array) ($validated['prizes'] ?? []),
$guard
);
return response()->json([
'ok' => true,
'message' => 'Competition created.',
'id' => $id,
], Response::HTTP_CREATED);
}
public function update(UpdateCompetitionWinnerRequest $request, int $id): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validated = $request->validated();
$updated = $this->service->updateCompetition(
$id,
$validated,
(array) ($validated['winner_overrides'] ?? []),
(array) ($validated['question_counts'] ?? []),
(array) ($validated['prizes'] ?? [])
);
if (! $updated) {
return response()->json([
'ok' => false,
'message' => 'Competition not found.',
], Response::HTTP_NOT_FOUND);
}
return response()->json([
'ok' => true,
'message' => 'Competition updated.',
'id' => $id,
]);
}
public function scores(Request $request, int $id): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $this->service->editScoresData($id, $this->intOrNull($request->query('class_section_id')));
if ($payload === null) {
return response()->json([
'ok' => false,
'message' => 'Competition not found.',
], Response::HTTP_NOT_FOUND);
}
return response()->json([
'ok' => true,
...$payload,
]);
}
public function saveScores(SaveCompetitionScoresRequest $request, int $id): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
$competition = $this->service->editScoresData($id, $this->intOrNull($request->input('class_section_id')));
if ($competition === null) {
return response()->json([
'ok' => false,
'message' => 'Competition not found.',
], Response::HTTP_NOT_FOUND);
}
$payload = $request->validated();
$saved = $this->service->saveScores(
$id,
(array) ($payload['scores'] ?? []),
$this->intOrNull($payload['class_section_id'] ?? null),
(array) ($competition['competition'] ?? [])
);
if (! $saved) {
return response()->json([
'ok' => false,
'message' => 'Select a class section before saving scores.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return response()->json([
'ok' => true,
'message' => 'Scores saved.',
'savedCount' => count((array) ($payload['scores'] ?? [])),
]);
}
public function preview(int $id): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $this->service->previewData($id);
if ($payload === null) {
return response()->json([
'ok' => false,
'message' => 'Competition not found.',
], Response::HTTP_NOT_FOUND);
}
return response()->json([
'ok' => true,
...$payload,
]);
}
public function winners(int $id): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $this->service->settingsFormData($id);
if ($payload === null || ! isset($payload['competition'])) {
return response()->json([
'ok' => false,
'message' => 'Competition not found.',
], Response::HTTP_NOT_FOUND);
}
return response()->json([
'ok' => true,
'competition' => $payload['competition'],
'rows' => $this->service->winnersListingRows($id),
'sectionMap' => $this->service->getClassSectionMap(),
]);
}
public function publish(int $id): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
if (! $this->service->publishWinners($id)) {
return response()->json([
'ok' => false,
'message' => 'Competition not found.',
], Response::HTTP_NOT_FOUND);
}
return response()->json([
'ok' => true,
'message' => 'Competition winners published.',
]);
}
public function lock(int $id): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
if (! $this->service->lockCompetition($id, $guard)) {
return response()->json([
'ok' => false,
'message' => 'Competition not found.',
], Response::HTTP_NOT_FOUND);
}
return response()->json([
'ok' => true,
'message' => 'Competition locked.',
]);
}
public function unlock(int $id): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
if (! $this->service->unlockCompetition($id)) {
return response()->json([
'ok' => false,
'message' => 'Competition not found.',
], Response::HTTP_NOT_FOUND);
}
return response()->json([
'ok' => true,
'message' => 'Competition unlocked.',
]);
}
public function exportQuiz(Request $request, int $id): JsonResponse
{
$guard = $this->authenticatedAdminOrForbidden();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validated = $request->validate([
'class_section_id' => ['nullable', 'integer'],
'semester' => ['nullable', 'string'],
'school_year' => ['nullable', 'string'],
]);
$schoolId = $this->intOrNull(optional(auth()->user())->school_id);
$result = $this->service->exportCompetitionToQuiz(
$id,
$this->intOrNull($validated['class_section_id'] ?? null) ?? 0,
$schoolId,
$guard,
$validated['semester'] ?? null,
$validated['school_year'] ?? null
);
return response()->json($result, $result['ok'] ? Response::HTTP_OK : Response::HTTP_UNPROCESSABLE_ENTITY);
}
/**
* @return int|JsonResponse
*/
private function authenticatedAdminOrForbidden(): int|JsonResponse
{
$user = $this->getCurrentUser();
if (! $user || (int) ($user->id ?? 0) <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], Response::HTTP_UNAUTHORIZED);
}
$roles = array_map('strtolower', (array) ($user->roles ?? []));
$allowed = ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'];
if (empty(array_intersect($roles, $allowed))) {
return response()->json(['ok' => false, 'message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
}
return (int) $user->id;
}
/**
* @param iterable<mixed> $rows
* @return array<int, mixed>
*/
private function iterableToArray(iterable $rows): array
{
return is_array($rows) ? $rows : iterator_to_array($rows, false);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function normalizeCompetitionRow(array $row): array
{
$id = $this->intOrNull($row['id'] ?? $row['competition_id'] ?? null);
if ($id !== null) {
$row['id'] = $id;
}
$classSectionId = $this->intOrNull($row['class_section_id'] ?? null);
$row['class_section_id'] = $classSectionId;
$row['is_locked'] = (bool) ($row['is_locked'] ?? false);
$row['is_published'] = (bool) ($row['is_published'] ?? false);
return $row;
}
/**
* @param mixed $row
* @return array<string, mixed>
*/
private function rowToArray(mixed $row): array
{
if (is_array($row)) {
return $row;
}
if ($row instanceof \JsonSerializable) {
$serialized = $row->jsonSerialize();
if (is_array($serialized)) {
return $serialized;
}
}
if (is_object($row) && method_exists($row, 'toArray')) {
$array = $row->toArray();
if (is_array($array)) {
return $array;
}
}
return (array) $row;
}
private function intOrNull(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value)) {
return (int) $value;
}
return null;
}
}
@@ -68,7 +68,7 @@ class StudentController extends BaseApiController
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'school_year' => ['nullable', 'string', 'max:50'],
'parent_id' => ['required', 'integer', 'min:1'],
'parent_id' => ['nullable', 'integer', 'min:1'],
'parent_ids' => ['nullable', 'array'],
'parent_ids.*' => ['integer', 'min:1'],
]);