940afe9319
API CI/CD / Validate (composer + pint) (push) Successful in 2m7s
API CI/CD / Test (PHPUnit) (push) Failing after 2m23s
API CI/CD / Build frontend assets (push) Successful in 2m18s
API CI/CD / Security audit (push) Successful in 31s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
215 lines
7.0 KiB
PHP
215 lines
7.0 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Auth;
|
|
|
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
|
use App\Models\User;
|
|
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
|
|
{
|
|
public function login(Request $request, ApiLoginSecurityService $security): JsonResponse
|
|
{
|
|
$payload = $request->all();
|
|
if ($request->isJson() && ($payload === [] || $payload === null)) {
|
|
$decoded = json_decode((string) $request->getContent(), true);
|
|
$payload = is_array($decoded) ? $decoded : [];
|
|
}
|
|
|
|
$email = strtolower(trim((string) ($payload['email'] ?? '')));
|
|
$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.',
|
|
], 400);
|
|
}
|
|
|
|
$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.',
|
|
], 429);
|
|
}
|
|
|
|
$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,
|
|
'message' => 'Invalid email or password.',
|
|
], 401);
|
|
}
|
|
|
|
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,
|
|
'message' => 'Invalid email or password.',
|
|
], 401);
|
|
}
|
|
|
|
// 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.',
|
|
], 403);
|
|
}
|
|
|
|
$security->resetFailedAttempts($user);
|
|
$security->logSuccessfulLogin($user->fresh(), $request);
|
|
|
|
$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.',
|
|
], 401);
|
|
}
|
|
|
|
$jwtToken = JWTAuth::fromUser($fresh);
|
|
|
|
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 {
|
|
$newToken = JWTAuth::parseToken()->refresh();
|
|
} catch (\Throwable $e) {
|
|
return $this->respondError('Token refresh failed.', 401);
|
|
}
|
|
|
|
return $this->respondSuccess([
|
|
'access_token' => $newToken,
|
|
'token_type' => 'bearer',
|
|
'expires_in' => (int) config('jwt.ttl') * 60,
|
|
], 'Token refreshed.');
|
|
}
|
|
|
|
public function me(): JsonResponse
|
|
{
|
|
$user = Auth::user();
|
|
if (! $user) {
|
|
return $this->respondError('Unauthorized.', 401);
|
|
}
|
|
|
|
$teacherContext = $user->teacherSessionContext();
|
|
|
|
$payload = [
|
|
'id' => (int) $user->id,
|
|
'firstname' => $user->firstname,
|
|
'lastname' => $user->lastname,
|
|
'email' => $user->email,
|
|
'class_section_id' => $teacherContext['class_section_id'],
|
|
'class_section_name' => $teacherContext['class_section_name'],
|
|
];
|
|
|
|
return response()->json([
|
|
'status' => true,
|
|
'message' => 'OK',
|
|
'data' => $payload,
|
|
'user' => $payload,
|
|
]);
|
|
}
|
|
|
|
public function logout(Request $request): JsonResponse
|
|
{
|
|
if ($request->hasAny(['action', 'purge', 'rewrite', 'created_by', 'updated_by', 'deleted_by', 'actor_id'])) {
|
|
return response()->json([
|
|
'status' => false,
|
|
'message' => 'Unsupported logout payload.',
|
|
], 422);
|
|
}
|
|
|
|
$token = $request->bearerToken();
|
|
if ($token && substr_count($token, '.') === 2) {
|
|
try {
|
|
JWTAuth::setToken($token)->invalidate();
|
|
} catch (\Throwable $e) {
|
|
// ignore invalidation errors
|
|
}
|
|
}
|
|
|
|
$user = $request->user();
|
|
if ($user && method_exists($user, 'currentAccessToken')) {
|
|
$current = $user->currentAccessToken();
|
|
if ($current) {
|
|
$current->delete();
|
|
}
|
|
}
|
|
|
|
return $this->respondSuccess(null, 'Logged out.');
|
|
}
|
|
}
|