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.', ]); } }