441 lines
16 KiB
PHP
441 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Helpers\ApiResponse;
|
|
use App\Models\Transaction;
|
|
use App\Models\User;
|
|
use App\Services\Auth\PasswordPolicyService;
|
|
use App\Services\MobileConfig\MobileConfigService;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Password;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Validation\ValidationException;
|
|
use OpenApi\Attributes as OA;
|
|
use Spatie\Permission\Models\Role;
|
|
|
|
class AuthController extends Controller
|
|
{
|
|
public function __construct(
|
|
protected MobileConfigService $mobileConfig
|
|
) {}
|
|
|
|
#[OA\Post(
|
|
path: '/v1/login',
|
|
operationId: 'login',
|
|
tags: ['Auth'],
|
|
summary: 'Authenticate a user',
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['email', 'password'],
|
|
properties: [
|
|
new OA\Property(property: 'email', type: 'string', format: 'email', example: 'user@example.com'),
|
|
new OA\Property(property: 'password', type: 'string', format: 'password', example: 'secret'),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Login successful',
|
|
content: new OA\JsonContent(properties: [
|
|
new OA\Property(property: 'status', type: 'string', example: 'success'),
|
|
new OA\Property(property: 'data', type: 'object', properties: [
|
|
new OA\Property(property: 'user', ref: '#/components/schemas/User'),
|
|
new OA\Property(property: 'token', type: 'string'),
|
|
]),
|
|
])
|
|
),
|
|
new OA\Response(response: 401, description: 'Invalid credentials'),
|
|
new OA\Response(response: 403, description: 'Account inactive or role not permitted'),
|
|
]
|
|
)]
|
|
public function login(Request $request)
|
|
{
|
|
$request->validate([
|
|
'email' => 'required|email',
|
|
'password' => 'required',
|
|
]);
|
|
|
|
$config = $this->mobileConfig->all();
|
|
$maxAttempts = $config['security_auth']['login_max_attempts'] ?? 5;
|
|
$throttleKey = 'login_attempt_'.$request->ip().'_'.$request->email;
|
|
|
|
if (RateLimiter::tooManyAttempts($throttleKey, $maxAttempts)) {
|
|
$seconds = RateLimiter::availableIn($throttleKey);
|
|
|
|
return ApiResponse::error("Too many login attempts. Please try again in {$seconds} seconds.", 429);
|
|
}
|
|
|
|
$user = User::where('email', $request->email)->first();
|
|
|
|
if (! $user || ! Hash::check($request->password, $user->password)) {
|
|
RateLimiter::hit($throttleKey, 60); // Lock for 60 seconds if limit reached
|
|
|
|
return ApiResponse::unauthorized('The provided credentials are incorrect.');
|
|
}
|
|
|
|
RateLimiter::clear($throttleKey);
|
|
|
|
if (isset($user->is_active) && ! $user->is_active) {
|
|
return ApiResponse::forbidden('Your account is currently inactive. Please contact support.');
|
|
}
|
|
|
|
if (class_exists(Role::class)) {
|
|
$allowedRoles = ['User', 'Administrator', 'Developer'];
|
|
if (! $user->hasAnyRole($allowedRoles)) {
|
|
return ApiResponse::forbidden('Access denied. Your role does not have permission to access the mobile application.');
|
|
}
|
|
}
|
|
|
|
$token = $user->createToken('mobile-app')->plainTextToken;
|
|
|
|
return ApiResponse::success(['user' => $user, 'token' => $token], 'Login successful');
|
|
}
|
|
|
|
#[OA\Post(
|
|
path: '/v1/register',
|
|
operationId: 'register',
|
|
tags: ['Auth'],
|
|
summary: 'Register a new user',
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['name', 'email', 'password'],
|
|
properties: [
|
|
new OA\Property(property: 'name', type: 'string', example: 'John Doe'),
|
|
new OA\Property(property: 'email', type: 'string', format: 'email'),
|
|
new OA\Property(property: 'password', type: 'string', minLength: 8),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(response: 201, description: 'Registration successful'),
|
|
new OA\Response(response: 422, description: 'Validation error'),
|
|
]
|
|
)]
|
|
public function register(Request $request)
|
|
{
|
|
$config = $this->mobileConfig->all();
|
|
|
|
// 1. Check if registration is enabled in Global Mobile Settings
|
|
if (! ($config['features']['enable_registration'] ?? true)) {
|
|
return ApiResponse::error('Registration is currently disabled by administrator.', 403);
|
|
}
|
|
|
|
// 2. Check if OTP is required
|
|
if (($config['features']['require_otp_registration'] ?? false) && ! $request->has('otp_token')) {
|
|
return ApiResponse::error('OTP verification is required for registration.', 403);
|
|
}
|
|
|
|
$request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'email' => 'required|string|email|max:255|unique:users',
|
|
'password' => ['required', 'string', PasswordPolicyService::getRules()],
|
|
]);
|
|
|
|
$user = User::create([
|
|
'name' => $request->name,
|
|
'email' => $request->email,
|
|
'password' => Hash::make($request->password),
|
|
'is_active' => true,
|
|
]);
|
|
|
|
if (class_exists(Role::class)) {
|
|
$user->assignRole('User');
|
|
}
|
|
|
|
$token = $user->createToken('mobile-app')->plainTextToken;
|
|
|
|
return ApiResponse::created(['user' => $user, 'token' => $token], 'Registration successful');
|
|
}
|
|
|
|
#[OA\Post(
|
|
path: '/v1/logout',
|
|
operationId: 'logout',
|
|
tags: ['Auth'],
|
|
summary: 'Revoke current access token',
|
|
security: [['sanctum' => []]],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Logged out successfully'),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
]
|
|
)]
|
|
public function logout(Request $request)
|
|
{
|
|
$request->user()->currentAccessToken()->delete();
|
|
|
|
return ApiResponse::success(null, 'Logged out successfully');
|
|
}
|
|
|
|
#[OA\Post(
|
|
path: '/v1/forgot-password',
|
|
operationId: 'forgotPassword',
|
|
tags: ['Auth'],
|
|
summary: 'Send password reset link',
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['email'],
|
|
properties: [new OA\Property(property: 'email', type: 'string', format: 'email')]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Reset link sent'),
|
|
new OA\Response(response: 422, description: 'Email not found or throttled'),
|
|
]
|
|
)]
|
|
public function forgotPassword(Request $request)
|
|
{
|
|
$request->validate([
|
|
'email' => 'required|email',
|
|
]);
|
|
|
|
$status = Password::sendResetLink($request->only('email'));
|
|
|
|
if ($status === Password::RESET_LINK_SENT) {
|
|
return ApiResponse::success(null, __($status));
|
|
}
|
|
|
|
return ApiResponse::error(__($status), 422);
|
|
}
|
|
|
|
#[OA\Post(
|
|
path: '/v1/profile/update',
|
|
operationId: 'updateProfile',
|
|
tags: ['Profile'],
|
|
summary: 'Update authenticated user profile',
|
|
security: [['sanctum' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['name', 'email'],
|
|
properties: [
|
|
new OA\Property(property: 'name', type: 'string'),
|
|
new OA\Property(property: 'email', type: 'string', format: 'email'),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Profile updated'),
|
|
new OA\Response(response: 422, description: 'Validation error'),
|
|
]
|
|
)]
|
|
public function updateProfile(Request $request)
|
|
{
|
|
$request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'email' => 'required|string|email|max:255|unique:users,email,'.Auth::id(),
|
|
]);
|
|
|
|
$user = Auth::user();
|
|
$user->update([
|
|
'name' => $request->name,
|
|
'email' => $request->email,
|
|
]);
|
|
|
|
return ApiResponse::success(['user' => $user->fresh()], 'Profile updated successfully');
|
|
}
|
|
|
|
#[OA\Post(
|
|
path: '/v1/profile/avatar',
|
|
operationId: 'updateAvatar',
|
|
tags: ['Profile'],
|
|
summary: 'Upload user avatar',
|
|
security: [['sanctum' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\MediaType(
|
|
mediaType: 'multipart/form-data',
|
|
schema: new OA\Schema(properties: [
|
|
new OA\Property(property: 'avatar', type: 'string', format: 'binary'),
|
|
])
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Avatar updated'),
|
|
new OA\Response(response: 422, description: 'Validation error'),
|
|
]
|
|
)]
|
|
public function updateAvatar(Request $request)
|
|
{
|
|
$request->validate([
|
|
'avatar' => 'required|image|mimes:jpeg,png,jpg,gif|max:10240',
|
|
]);
|
|
|
|
$user = Auth::user();
|
|
|
|
if ($request->hasFile('avatar')) {
|
|
$user->clearMediaCollection('avatar');
|
|
$media = $user->addMediaFromRequest('avatar')->toMediaCollection('avatar');
|
|
|
|
return ApiResponse::success([
|
|
'avatar_url' => $media->getFullUrl(),
|
|
'user' => $user->fresh(),
|
|
], 'Avatar updated successfully');
|
|
}
|
|
|
|
return ApiResponse::error('No file uploaded', 400);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/v1/dashboard',
|
|
operationId: 'getDashboard',
|
|
tags: ['Dashboard'],
|
|
summary: 'Get dashboard summary data',
|
|
security: [['sanctum' => []]],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Dashboard data'),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
]
|
|
)]
|
|
public function getDashboardData(Request $request)
|
|
{
|
|
$user = Auth::user();
|
|
$cacheKey = "user_dashboard_v2_{$user->id}";
|
|
|
|
$data = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($user) {
|
|
$transactions = Transaction::where('user_id', $user->id)->latest()->take(10)->get();
|
|
|
|
$totalBalance = Transaction::where('user_id', $user->id)
|
|
->selectRaw("SUM(CASE WHEN type = 'income' THEN amount ELSE -amount END) as balance")
|
|
->value('balance') ?? 0;
|
|
|
|
return [
|
|
'balance' => number_format($totalBalance, 2),
|
|
'transactions' => $transactions,
|
|
'stats' => [
|
|
'income' => Transaction::where('user_id', $user->id)->where('type', 'income')->sum('amount'),
|
|
'expense' => Transaction::where('user_id', $user->id)->where('type', 'expense')->sum('amount'),
|
|
],
|
|
];
|
|
});
|
|
|
|
return ApiResponse::success($data);
|
|
}
|
|
|
|
#[OA\Post(
|
|
path: '/v1/profile/password',
|
|
operationId: 'updatePassword',
|
|
tags: ['Profile'],
|
|
summary: 'Change authenticated user password',
|
|
security: [['sanctum' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['current_password', 'password', 'password_confirmation'],
|
|
properties: [
|
|
new OA\Property(property: 'current_password', type: 'string'),
|
|
new OA\Property(property: 'password', type: 'string', minLength: 8),
|
|
new OA\Property(property: 'password_confirmation', type: 'string'),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Password updated'),
|
|
new OA\Response(response: 422, description: 'Current password incorrect'),
|
|
]
|
|
)]
|
|
public function updatePassword(Request $request)
|
|
{
|
|
$request->validate([
|
|
'current_password' => 'required',
|
|
'password' => 'required|string|min:8|confirmed',
|
|
]);
|
|
|
|
$user = Auth::user();
|
|
|
|
if (! Hash::check($request->current_password, $user->password)) {
|
|
return ApiResponse::error('The current password you entered is incorrect.', 422);
|
|
}
|
|
|
|
try {
|
|
PasswordPolicyService::checkHistory($user, $request->password);
|
|
} catch (ValidationException $e) {
|
|
return ApiResponse::error($e->errors()['password'][0] ?? 'Password does not meet policy requirements.', 422);
|
|
}
|
|
|
|
$passwordHash = Hash::make($request->password);
|
|
$user->update(['password' => $passwordHash]);
|
|
PasswordPolicyService::recordPasswordChange($user, $passwordHash);
|
|
|
|
return ApiResponse::success(null, 'Password updated successfully.');
|
|
}
|
|
|
|
#[OA\Delete(
|
|
path: '/v1/profile/delete',
|
|
operationId: 'deleteAccount',
|
|
tags: ['Profile'],
|
|
summary: 'Permanently delete authenticated user account',
|
|
security: [['sanctum' => []]],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['password'],
|
|
properties: [
|
|
new OA\Property(property: 'password', type: 'string', description: 'Current password to confirm deletion'),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Account deleted'),
|
|
new OA\Response(response: 422, description: 'Password incorrect or missing'),
|
|
]
|
|
)]
|
|
public function deleteAccount(Request $request)
|
|
{
|
|
$request->validate([
|
|
'password' => 'required|string',
|
|
]);
|
|
|
|
$user = Auth::user();
|
|
|
|
if (! Hash::check($request->password, $user->password)) {
|
|
return ApiResponse::error('The password you entered is incorrect.', 422);
|
|
}
|
|
|
|
$user->tokens()->delete();
|
|
$user->delete();
|
|
|
|
return ApiResponse::success(null, 'Your account has been deleted permanently.');
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/v1/user',
|
|
operationId: 'getUser',
|
|
tags: ['Auth'],
|
|
summary: 'Get authenticated user',
|
|
security: [['sanctum' => []]],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'User object'),
|
|
new OA\Response(response: 401, description: 'Unauthenticated'),
|
|
]
|
|
)]
|
|
public function user(Request $request)
|
|
{
|
|
return ApiResponse::success(['user' => $request->user()]);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/v1/app-config',
|
|
operationId: 'getAppConfig',
|
|
tags: ['Config'],
|
|
summary: 'Get public app configuration (branding, taglines)',
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'App config'),
|
|
]
|
|
)]
|
|
public function getAppConfig()
|
|
{
|
|
return ApiResponse::success([
|
|
'logo' => asset(get_setting('app_logo', 'assets/img/logo.png')),
|
|
'tagline1' => get_setting('app_tagline1', 'Welcome'),
|
|
'tagline2' => strip_tags(get_setting('app_tagline2', 'Manage your assets efficiently')),
|
|
'footer' => get_setting('footer_text', '© 2026 Your App'),
|
|
]);
|
|
}
|
|
}
|