Initial commit - WASL Digital Wallet
This commit is contained in:
324
Backend/app/Http/Controllers/Api/AuthController.php
Normal file
324
Backend/app/Http/Controllers/Api/AuthController.php
Normal file
@@ -0,0 +1,324 @@
|
||||
<?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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
58
Backend/app/Http/Middleware/AuditRequestMiddleware.php
Normal file
58
Backend/app/Http/Middleware/AuditRequestMiddleware.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuditRequestMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
// Audit only write/mutation operations or auth requests
|
||||
$method = $request->method();
|
||||
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE']) || $request->routeIs('*.sensitive')) {
|
||||
$user = Auth::user();
|
||||
|
||||
// Mask sensitive fields in request payload
|
||||
$payload = $request->all();
|
||||
$sensitiveKeys = ['password', 'password_confirmation', 'pin', 'pin_confirmation', 'pin_hash', 'code', 'token', 'key', 'national_id', 'card_number'];
|
||||
foreach ($sensitiveKeys as $key) {
|
||||
if (isset($payload[$key])) {
|
||||
$payload[$key] = '********';
|
||||
}
|
||||
}
|
||||
|
||||
AuditLog::record([
|
||||
'user_id' => $user?->id,
|
||||
'actor_id' => $user?->id,
|
||||
'action' => 'api_request_' . strtolower($method),
|
||||
'subject_type' => 'Request',
|
||||
'subject_id' => null,
|
||||
'old_values' => null,
|
||||
'new_values' => [
|
||||
'url' => $request->fullUrl(),
|
||||
'method' => $method,
|
||||
'status' => $response->getStatusCode(),
|
||||
'payload' => $payload,
|
||||
],
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'device_id' => $request->header('X-Device-Id'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
79
Backend/app/Http/Middleware/IdempotencyMiddleware.php
Normal file
79
Backend/app/Http/Middleware/IdempotencyMiddleware.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class IdempotencyMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
// Only run for mutations (POST, PUT, PATCH, DELETE)
|
||||
if (!in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$idempotencyKey = $request->header('Idempotency-Key') ?? $request->header('X-Idempotency-Key');
|
||||
|
||||
if (!$idempotencyKey) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Clean key
|
||||
$key = 'idempotency:' . hash('sha256', $idempotencyKey);
|
||||
$lockKey = $key . ':lock';
|
||||
$ttl = config('wasl.security.idempotency.ttl_seconds', 86400); // 24 hours default
|
||||
|
||||
// Acquire lock using Redis SETNX (Swoole safe)
|
||||
$lockAcquired = Redis::set($lockKey, 'locked', 'EX', 10, 'NX');
|
||||
|
||||
if (!$lockAcquired) {
|
||||
return response()->json([
|
||||
'error' => 'Conflict',
|
||||
'message' => 'A request with this Idempotency-Key is already in progress.',
|
||||
], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if we have a cached response
|
||||
$cached = Redis::get($key);
|
||||
if ($cached) {
|
||||
$data = json_decode($cached, true);
|
||||
|
||||
// Release lock
|
||||
Redis::del($lockKey);
|
||||
|
||||
return response($data['content'], $data['status'], $data['headers']);
|
||||
}
|
||||
|
||||
// Execute request
|
||||
$response = $next($request);
|
||||
|
||||
// Only cache successful or non-server-error responses (2xx and 4xx, exclude 5xx)
|
||||
if ($response->getStatusCode() < 500) {
|
||||
$cacheData = json_encode([
|
||||
'status' => $response->getStatusCode(),
|
||||
'headers' => collect($response->headers->all())->map(fn($val) => $val[0])->toArray(),
|
||||
'content' => $response->getContent(),
|
||||
]);
|
||||
Redis::set($key, $cacheData, 'EX', $ttl);
|
||||
}
|
||||
|
||||
return $response;
|
||||
|
||||
} finally {
|
||||
// Always release lock
|
||||
Redis::del($lockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
70
Backend/app/Http/Middleware/JwtAuthenticate.php
Normal file
70
Backend/app/Http/Middleware/JwtAuthenticate.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\JwtService;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class JwtAuthenticate
|
||||
{
|
||||
protected JwtService $jwtService;
|
||||
|
||||
public function __construct(JwtService $jwtService)
|
||||
{
|
||||
$this->jwtService = $jwtService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$authorization = $request->header('Authorization');
|
||||
|
||||
if (!$authorization || !str_starts_with($authorization, 'Bearer ')) {
|
||||
return response()->json([
|
||||
'error' => 'Unauthorized',
|
||||
'message' => 'Authorization token is missing or malformed.',
|
||||
], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$token = substr($authorization, 7);
|
||||
$payload = $this->jwtService->validateToken($token);
|
||||
|
||||
if (!$payload) {
|
||||
return response()->json([
|
||||
'error' => 'Unauthorized',
|
||||
'message' => 'Authorization token is invalid or expired.',
|
||||
], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$user = User::where('uuid', $payload['sub'])->first();
|
||||
|
||||
if (!$user) {
|
||||
return response()->json([
|
||||
'error' => 'Unauthorized',
|
||||
'message' => 'User associated with this token does not exist.',
|
||||
], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if ($user->status === \App\Enums\UserStatus::BANNED || $user->status === \App\Enums\UserStatus::SUSPENDED) {
|
||||
return response()->json([
|
||||
'error' => 'Forbidden',
|
||||
'message' => 'Your account has been ' . $user->status->value . '.',
|
||||
], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
// Set authenticated user
|
||||
Auth::setUser($user);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
63
Backend/app/Http/Middleware/ThrottleSensitiveActions.php
Normal file
63
Backend/app/Http/Middleware/ThrottleSensitiveActions.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ThrottleSensitiveActions
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
|
||||
* @param string $action
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, string $action)
|
||||
{
|
||||
$config = config("wasl.throttle.{$action}");
|
||||
|
||||
if (!$config) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$maxAttempts = $config['max'] ?? 5;
|
||||
$decayMinutes = $config['minutes'] ?? 1;
|
||||
|
||||
// Generate key based on action, user_id (if logged in) or phone hash / IP
|
||||
$userId = $request->user()?->id;
|
||||
$ip = $request->ip();
|
||||
|
||||
$phoneHash = '';
|
||||
if ($request->has('phone_number')) {
|
||||
$phoneHash = hash_phone($request->input('phone_number'));
|
||||
}
|
||||
|
||||
$key = 'throttle:' . $action . ':' . ($userId ?: ($phoneHash ?: $ip));
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, $maxAttempts)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Too Many Requests',
|
||||
'message' => "Too many attempts for {$action}. Please retry in {$seconds} seconds.",
|
||||
'retry_after' => $seconds,
|
||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, $decayMinutes * 60);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
// Optional: clear the rate limit on successful authentication for login
|
||||
if ($action === 'login' && $response->getStatusCode() === Response::HTTP_OK) {
|
||||
RateLimiter::clear($key);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user