archetecture security fix
This commit is contained in:
@@ -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'],
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user