Files
wasl/Backend/app/Http/Controllers/Api/AuthController.php
2026-06-20 21:55:06 +03:00

325 lines
10 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\User;
use App\Models\Wallet;
use App\Models\OtpCode;
use App\Models\UserDevice;
use App\Models\AuditLog;
use App\Services\JwtService;
use App\Enums\UserStatus;
use App\Enums\WalletStatus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class AuthController extends Controller
{
protected JwtService $jwtService;
public function __construct(JwtService $jwtService)
{
$this->jwtService = $jwtService;
}
/**
* POST /api/register
*/
public function register(Request $request)
{
$validator = Validator::make($request->all(), [
'full_name' => 'required|string|max:150',
'phone_number' => 'required|string',
'password' => 'required|string|min:8',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$normalizedPhone = normalize_phone_number($request->phone_number);
$phoneHash = hash_phone($normalizedPhone);
// Check uniqueness
if (User::where('phone_hash', $phoneHash)->exists()) {
return response()->json([
'error' => 'Conflict',
'message' => 'A user with this phone number already exists.',
], Response::HTTP_CONFLICT);
}
$user = DB::transaction(function () use ($request, $normalizedPhone, $phoneHash) {
$user = User::create([
'full_name' => $request->full_name,
'phone_number' => $normalizedPhone,
'phone_hash' => $phoneHash,
'password' => Hash::make($request->password),
'status' => UserStatus::PENDING,
'kyc_level' => 0,
]);
// Create default wallet
Wallet::create([
'user_id' => $user->id,
'currency_code' => 'SYP',
'balance_minor' => 0,
'balance_pending_minor' => 0,
'status' => WalletStatus::ACTIVE,
]);
return $user;
});
// Generate Registration OTP
$otp = '123456'; // Default mockup code
if (app()->environment('production')) {
$otp = (string) rand(100000, 999999);
}
OtpCode::create([
'user_id' => $user->id,
'purpose' => 'login',
'code_hash' => Hash::make($otp),
'channel' => 'sms',
'attempts' => 0,
'expires_at' => now()->addMinutes(5),
]);
// Audit log registration
AuditLog::record([
'user_id' => $user->id,
'actor_id' => $user->id,
'action' => 'user_registered',
'subject_type' => User::class,
'subject_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return response()->json([
'message' => 'User registered successfully. Verification OTP sent.',
'uuid' => $user->uuid,
'otp' => app()->environment('production') ? null : $otp, // Return for testing
], Response::HTTP_CREATED);
}
/**
* POST /api/login
*/
public function login(Request $request)
{
$validator = Validator::make($request->all(), [
'phone_number' => 'required|string',
'password' => 'required|string',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$phoneHash = hash_phone($request->phone_number);
$user = User::where('phone_hash', $phoneHash)->first();
if (!$user) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'Invalid phone number or password.',
], Response::HTTP_UNAUTHORIZED);
}
// Brute-force protection
if ($user->isLocked()) {
return response()->json([
'error' => 'Locked',
'message' => 'Account is locked. Try again later.',
], Response::HTTP_LOCKED);
}
if (!Hash::check($request->password, $user->password)) {
$user->increment('failed_login_count');
if ($user->failed_login_count >= 5) {
$user->update(['locked_until' => now()->addMinutes(30)]);
}
return response()->json([
'error' => 'Unauthorized',
'message' => 'Invalid phone number or password.',
], Response::HTTP_UNAUTHORIZED);
}
// Reset failed count
$user->update([
'failed_login_count' => 0,
'locked_until' => null,
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
]);
// Generate Login OTP
$otp = '123456';
if (app()->environment('production')) {
$otp = (string) rand(100000, 999999);
}
OtpCode::create([
'user_id' => $user->id,
'purpose' => 'login',
'code_hash' => Hash::make($otp),
'channel' => 'sms',
'attempts' => 0,
'expires_at' => now()->addMinutes(5),
]);
return response()->json([
'message' => 'Credentials verified. Verification OTP sent.',
'uuid' => $user->uuid,
'otp' => app()->environment('production') ? null : $otp,
]);
}
/**
* POST /api/otp/verify
*/
public function verifyOtp(Request $request)
{
$validator = Validator::make($request->all(), [
'uuid' => 'required|uuid',
'code' => 'required|string|size:6',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$user = User::where('uuid', $request->uuid)->first();
if (!$user) {
return response()->json([
'error' => 'NotFound',
'message' => 'User not found.',
], Response::HTTP_NOT_FOUND);
}
$otp = OtpCode::where('user_id', $user->id)
->where('purpose', 'login')
->whereNull('used_at')
->orderBy('id', 'desc')
->first();
if (!$otp || $otp->isExpired()) {
return response()->json([
'error' => 'BadRequest',
'message' => 'OTP code is expired or invalid.',
], Response::HTTP_BAD_REQUEST);
}
if ($otp->attempts >= 3) {
$otp->markUsed(); // invalidate
return response()->json([
'error' => 'Locked',
'message' => 'Too many failed verification attempts. Please request a new OTP.',
], Response::HTTP_LOCKED);
}
$otp->incrementAttempts();
if (!Hash::check($request->code, $otp->code_hash)) {
return response()->json([
'error' => 'Unauthorized',
'message' => 'Invalid OTP code.',
], Response::HTTP_UNAUTHORIZED);
}
// Success
$otp->markUsed();
DB::transaction(function () use ($user) {
$updates = [];
if (is_null($user->phone_verified_at)) {
$updates['phone_verified_at'] = now();
}
if ($user->status === UserStatus::PENDING) {
$updates['status'] = UserStatus::ACTIVE;
}
if ($user->kyc_level === 0) {
$updates['kyc_level'] = 1; // phone verified kyc tier
}
if (!empty($updates)) {
$user->update($updates);
}
});
// Register device if device fingerprint exists
$deviceId = $request->header('X-Device-Id');
if ($deviceId) {
UserDevice::updateOrCreate(
['user_id' => $user->id, 'device_fingerprint' => $deviceId],
[
'device_name' => $request->header('User-Agent') ?? 'Unknown',
'platform' => str_contains(strtolower($request->header('User-Agent')), 'android') ? 'android' : 'ios',
'last_seen_at' => now(),
]
);
}
$token = $this->jwtService->generateToken($user, $deviceId);
// Audit log login
AuditLog::record([
'user_id' => $user->id,
'actor_id' => $user->id,
'action' => 'user_logged_in',
'subject_type' => User::class,
'subject_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'device_id' => $deviceId,
]);
return response()->json([
'message' => 'Verification successful.',
'access_token' => $token,
'token_type' => 'Bearer',
'user' => $user,
]);
}
/**
* POST /api/pin/setup
*/
public function setupPin(Request $request)
{
$validator = Validator::make($request->all(), [
'pin' => 'required|string|size:6|regex:/^[0-9]+$/',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$user = auth()->user();
$user->update([
'pin_hash' => Hash::make($request->pin),
]);
AuditLog::record([
'user_id' => $user->id,
'actor_id' => $user->id,
'action' => 'pin_updated',
'subject_type' => User::class,
'subject_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return response()->json([
'message' => 'Security PIN updated successfully.',
]);
}
}