init commit
This commit is contained in:
447
app/Services/AuthService.php
Normal file
447
app/Services/AuthService.php
Normal file
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
* Professional Resume Builder - Enterprise Auth System
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Interfaces\UserRepositoryInterface;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
* Handles user authentication, registration, and security features with repository pattern
|
||||
*/
|
||||
class AuthService
|
||||
{
|
||||
/**
|
||||
* User repository instance
|
||||
*/
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
/**
|
||||
* Maximum login attempts per minute
|
||||
*/
|
||||
protected const MAX_LOGIN_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Login throttle key prefix
|
||||
*/
|
||||
protected const THROTTLE_KEY_PREFIX = 'login_attempts:';
|
||||
|
||||
/**
|
||||
* Account lockout duration in minutes
|
||||
*/
|
||||
protected const LOCKOUT_DURATION = 15;
|
||||
|
||||
/**
|
||||
* Create a new service instance with dependency injection
|
||||
*/
|
||||
public function __construct(UserRepositoryInterface $userRepository)
|
||||
{
|
||||
$this->userRepository = $userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt user authentication with comprehensive security measures
|
||||
*
|
||||
* Implements enterprise-grade authentication with:
|
||||
* - Rate limiting to prevent brute force attacks
|
||||
* - Account lockout mechanisms for security
|
||||
* - Detailed security event logging
|
||||
* - Structured response format for consistent handling
|
||||
*
|
||||
* @param array $credentials User login credentials (email, password, remember)
|
||||
* @return array Structured authentication result with success status and details
|
||||
* @throws ValidationException When rate limiting is exceeded or validation fails
|
||||
* @throws Exception When system errors occur during authentication
|
||||
*/
|
||||
public function attemptLogin(array $credentials): array
|
||||
{
|
||||
$email = $credentials['email'];
|
||||
$password = $credentials['password'];
|
||||
$remember = $credentials['remember'] ?? false;
|
||||
|
||||
try {
|
||||
$this->checkRateLimit($email);
|
||||
|
||||
if ($this->userRepository->isAccountLocked($email)) {
|
||||
Log::warning('Login attempt on locked account', [
|
||||
'email' => $email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'account_locked',
|
||||
'message' => 'Account is temporarily locked'
|
||||
];
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findByEmail($email);
|
||||
|
||||
if (!$user) {
|
||||
$this->incrementRateLimit($email);
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'invalid_credentials',
|
||||
'message' => 'User not found'
|
||||
];
|
||||
}
|
||||
|
||||
if (!$user->is_active) {
|
||||
$this->incrementRateLimit($email);
|
||||
|
||||
Log::warning('Login attempt on inactive account', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'account_inactive',
|
||||
'message' => 'Account is deactivated'
|
||||
];
|
||||
}
|
||||
|
||||
if (!Hash::check($password, $user->password)) {
|
||||
$this->incrementRateLimit($email);
|
||||
$this->userRepository->incrementLoginAttempts($email);
|
||||
|
||||
$updatedUser = $this->userRepository->findByEmail($email);
|
||||
if ($updatedUser && $updatedUser->login_attempts >= self::MAX_LOGIN_ATTEMPTS) {
|
||||
$this->userRepository->lockAccount($email, self::LOCKOUT_DURATION);
|
||||
|
||||
Log::warning('Account locked due to excessive failed attempts', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $email,
|
||||
'attempts' => $updatedUser->login_attempts
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'too_many_attempts',
|
||||
'message' => 'Account locked due to failed attempts'
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'invalid_credentials',
|
||||
'message' => 'Invalid password'
|
||||
];
|
||||
}
|
||||
|
||||
Auth::login($user, $remember);
|
||||
|
||||
$this->userRepository->updateLastLogin($user->id, request()->ip());
|
||||
$this->userRepository->resetLoginAttempts($email);
|
||||
$this->clearRateLimit($email);
|
||||
|
||||
Log::info('Successful user authentication', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $email,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'user' => $user,
|
||||
'message' => 'Login successful'
|
||||
];
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (Exception $e) {
|
||||
Log::error('Authentication service error', [
|
||||
'error_message' => $e->getMessage(),
|
||||
'email' => $email,
|
||||
'ip_address' => request()->ip(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'reason' => 'system_error',
|
||||
'message' => 'System error occurred'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new user account with comprehensive security validation
|
||||
*
|
||||
* Implements secure user registration with:
|
||||
* - Duplicate email detection using repository layer
|
||||
* - Secure password hashing with bcrypt
|
||||
* - Security-focused default settings
|
||||
* - Comprehensive audit logging
|
||||
* - Prepared data structure for repository storage
|
||||
*
|
||||
* @param array $userData User registration data (first_name, last_name, email, password)
|
||||
* @return User The created user instance
|
||||
* @throws ValidationException When email already exists or validation fails
|
||||
* @throws Exception When user creation fails due to system errors
|
||||
*/
|
||||
public function createUser(array $userData): User
|
||||
{
|
||||
try {
|
||||
if ($this->userRepository->findByEmail($userData['email'])) {
|
||||
Log::warning('Registration attempt with existing email', [
|
||||
'email' => $userData['email'],
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => 'An account with this email address already exists.',
|
||||
]);
|
||||
}
|
||||
|
||||
$preparedData = [
|
||||
'first_name' => $userData['first_name'],
|
||||
'last_name' => $userData['last_name'],
|
||||
'email' => $userData['email'],
|
||||
'password' => Hash::make($userData['password']),
|
||||
'status' => 'active',
|
||||
'locale' => $userData['locale'] ?? 'en',
|
||||
'timezone' => $userData['timezone'] ?? 'UTC',
|
||||
'login_attempts' => 0,
|
||||
'locked_until' => null,
|
||||
'email_verified_at' => null,
|
||||
'last_login_at' => null,
|
||||
'last_login_ip' => null
|
||||
];
|
||||
|
||||
$user = $this->userRepository->create($preparedData);
|
||||
|
||||
if ($user) {
|
||||
Log::info('New user account created successfully', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent()
|
||||
]);
|
||||
}
|
||||
|
||||
return $user;
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (Exception $e) {
|
||||
Log::error('User creation failed', [
|
||||
'error_message' => $e->getMessage(),
|
||||
'email' => $userData['email'] ?? 'unknown',
|
||||
'ip_address' => request()->ip(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
throw new Exception('Failed to create user account');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile information
|
||||
*/
|
||||
public function updateProfile(User $user, array $profileData): User
|
||||
{
|
||||
unset($profileData['password'], $profileData['email'], $profileData['is_active']);
|
||||
|
||||
$user->update($profileData);
|
||||
|
||||
logger()->info('User profile updated', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
*/
|
||||
public function changePassword(User $user, string $currentPassword, string $newPassword): bool
|
||||
{
|
||||
if (!Hash::check($currentPassword, $user->password)) {
|
||||
throw new \InvalidArgumentException('Current password is incorrect.');
|
||||
}
|
||||
|
||||
$user->update(['password' => Hash::make($newPassword)]);
|
||||
|
||||
logger()->info('User password changed', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate user account
|
||||
*/
|
||||
public function deactivateAccount(User $user): bool
|
||||
{
|
||||
$user->update(['is_active' => false]);
|
||||
|
||||
logger()->info('User account deactivated', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate user account
|
||||
*/
|
||||
public function activateAccount(User $user): bool
|
||||
{
|
||||
$user->update(['is_active' => true]);
|
||||
|
||||
logger()->info('User account activated', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has exceeded login rate limit
|
||||
*/
|
||||
protected function checkRateLimit(string $email): void
|
||||
{
|
||||
$key = $this->getRateLimitKey($email);
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, self::MAX_LOGIN_ATTEMPTS)) {
|
||||
$seconds = RateLimiter::availableIn($key);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => "Too many login attempts. Please try again in {$seconds} seconds.",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment rate limit counter
|
||||
*/
|
||||
protected function incrementRateLimit(string $email): void
|
||||
{
|
||||
$key = $this->getRateLimitKey($email);
|
||||
RateLimiter::hit($key, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear rate limit counter
|
||||
*/
|
||||
protected function clearRateLimit(string $email): void
|
||||
{
|
||||
$key = $this->getRateLimitKey($email);
|
||||
RateLimiter::clear($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit key for email
|
||||
*/
|
||||
protected function getRateLimitKey(string $email): string
|
||||
{
|
||||
return self::THROTTLE_KEY_PREFIX . $email . '|' . request()->ip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset notification
|
||||
*/
|
||||
public function sendPasswordResetNotification(string $email): bool
|
||||
{
|
||||
$user = User::where('email', $email)->first();
|
||||
|
||||
if (!$user) {
|
||||
// Don't reveal if email exists or not for security
|
||||
return true;
|
||||
}
|
||||
|
||||
// Generate and send password reset token
|
||||
// Implementation depends on your notification preferences
|
||||
// $user->sendPasswordResetNotification($token);
|
||||
|
||||
logger()->info('Password reset requested', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email address
|
||||
*/
|
||||
public function verifyEmail(User $user): bool
|
||||
{
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user->markEmailAsVerified();
|
||||
|
||||
logger()->info('Email verified', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user statistics
|
||||
*/
|
||||
public function getUserStats(User $user): array
|
||||
{
|
||||
return [
|
||||
'account_created' => $user->created_at,
|
||||
'last_login' => $user->last_login_at,
|
||||
'profile_completion' => $this->calculateProfileCompletion($user),
|
||||
'total_resumes' => $user->resumes()->count(),
|
||||
'completed_resumes' => $user->completedResumes()->count(),
|
||||
'is_verified' => $user->hasVerifiedEmail(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate profile completion percentage
|
||||
*/
|
||||
protected function calculateProfileCompletion(User $user): int
|
||||
{
|
||||
$fields = [
|
||||
'first_name', 'last_name', 'email', 'phone',
|
||||
'address', 'city', 'country', 'profession'
|
||||
];
|
||||
|
||||
$completed = 0;
|
||||
foreach ($fields as $field) {
|
||||
if (!empty($user->$field)) {
|
||||
$completed++;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) (($completed / count($fields)) * 100);
|
||||
}
|
||||
}
|
||||
127
app/Services/ProfileCompletionService.php
Normal file
127
app/Services/ProfileCompletionService.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Profile Completion Service
|
||||
*
|
||||
* Handles user profile completion calculations and analytics.
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @since February 2025
|
||||
*/
|
||||
class ProfileCompletionService
|
||||
{
|
||||
/**
|
||||
* Required fields for profile completion
|
||||
*/
|
||||
private const REQUIRED_FIELDS = [
|
||||
'first_name' => 10,
|
||||
'last_name' => 10,
|
||||
'email' => 15,
|
||||
'phone' => 10,
|
||||
'bio' => 20,
|
||||
'website' => 10,
|
||||
'linkedin' => 15,
|
||||
'github' => 10,
|
||||
];
|
||||
|
||||
/**
|
||||
* Calculate profile completion percentage
|
||||
*/
|
||||
public function calculateCompletion(User $user): int
|
||||
{
|
||||
$totalWeight = array_sum(self::REQUIRED_FIELDS);
|
||||
$completedWeight = 0;
|
||||
|
||||
foreach (self::REQUIRED_FIELDS as $field => $weight) {
|
||||
if (!empty($user->$field)) {
|
||||
$completedWeight += $weight;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) (($completedWeight / $totalWeight) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get missing profile fields
|
||||
*/
|
||||
public function getMissingFields(User $user): array
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
foreach (self::REQUIRED_FIELDS as $field => $weight) {
|
||||
if (empty($user->$field)) {
|
||||
$missing[] = [
|
||||
'field' => $field,
|
||||
'label' => $this->getFieldLabel($field),
|
||||
'weight' => $weight,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly field labels
|
||||
*/
|
||||
private function getFieldLabel(string $field): string
|
||||
{
|
||||
$labels = [
|
||||
'first_name' => 'First Name',
|
||||
'last_name' => 'Last Name',
|
||||
'email' => 'Email Address',
|
||||
'phone' => 'Phone Number',
|
||||
'bio' => 'Professional Bio',
|
||||
'website' => 'Website',
|
||||
'linkedin' => 'LinkedIn Profile',
|
||||
'github' => 'GitHub Profile',
|
||||
];
|
||||
|
||||
return $labels[$field] ?? ucfirst(str_replace('_', ' ', $field));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if profile is considered complete
|
||||
*/
|
||||
public function isProfileComplete(User $user): bool
|
||||
{
|
||||
return $this->calculateCompletion($user) >= 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile completion statistics
|
||||
*/
|
||||
public function getCompletionStats(User $user): array
|
||||
{
|
||||
$completion = $this->calculateCompletion($user);
|
||||
$missingFields = $this->getMissingFields($user);
|
||||
|
||||
return [
|
||||
'completion_percentage' => $completion,
|
||||
'is_complete' => $this->isProfileComplete($user),
|
||||
'missing_fields' => $missingFields,
|
||||
'missing_count' => count($missingFields),
|
||||
'status' => $this->getCompletionStatus($completion),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completion status message
|
||||
*/
|
||||
private function getCompletionStatus(int $completion): string
|
||||
{
|
||||
if ($completion >= 90) {
|
||||
return 'excellent';
|
||||
} elseif ($completion >= 70) {
|
||||
return 'good';
|
||||
} elseif ($completion >= 50) {
|
||||
return 'fair';
|
||||
} else {
|
||||
return 'incomplete';
|
||||
}
|
||||
}
|
||||
}
|
||||
380
app/Services/ResumeService.php
Normal file
380
app/Services/ResumeService.php
Normal file
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Resume Service
|
||||
* Professional Resume Builder - Resume Management Service
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Resume;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\Response;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
|
||||
/**
|
||||
* Resume Service
|
||||
* Handles resume creation, management, and operations
|
||||
*/
|
||||
class ResumeService
|
||||
{
|
||||
/**
|
||||
* Available resume templates
|
||||
*/
|
||||
protected array $templates = [
|
||||
'professional' => [
|
||||
'id' => 'professional',
|
||||
'name' => 'Professional Classic',
|
||||
'description' => 'Clean and professional design perfect for corporate environments',
|
||||
'preview' => 'templates/professional-preview.jpg',
|
||||
'category' => 'Professional'
|
||||
],
|
||||
'modern' => [
|
||||
'id' => 'modern',
|
||||
'name' => 'Modern Creative',
|
||||
'description' => 'Contemporary design with creative elements for modern companies',
|
||||
'preview' => 'templates/modern-preview.jpg',
|
||||
'category' => 'Creative'
|
||||
],
|
||||
'executive' => [
|
||||
'id' => 'executive',
|
||||
'name' => 'Executive',
|
||||
'description' => 'Sophisticated layout for senior-level positions',
|
||||
'preview' => 'templates/executive-preview.jpg',
|
||||
'category' => 'Executive'
|
||||
],
|
||||
'minimal' => [
|
||||
'id' => 'minimal',
|
||||
'name' => 'Minimal Clean',
|
||||
'description' => 'Simple and clean design focusing on content',
|
||||
'preview' => 'templates/minimal-preview.jpg',
|
||||
'category' => 'Minimal'
|
||||
],
|
||||
'technical' => [
|
||||
'id' => 'technical',
|
||||
'name' => 'Technical',
|
||||
'description' => 'Optimized for technical and engineering roles',
|
||||
'preview' => 'templates/technical-preview.jpg',
|
||||
'category' => 'Technical'
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all resumes for a specific user
|
||||
*/
|
||||
public function getUserResumes(int $userId): Collection
|
||||
{
|
||||
return Resume::where('user_id', $userId)
|
||||
->orderBy('updated_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new resume
|
||||
*/
|
||||
public function createResume(array $resumeData): Resume
|
||||
{
|
||||
// Set default values
|
||||
$resumeData['is_active'] = true;
|
||||
$resumeData['is_completed'] = false;
|
||||
$resumeData['is_public'] = false;
|
||||
|
||||
// Initialize empty sections
|
||||
$resumeData['personal_info'] = $resumeData['personal_info'] ?? [];
|
||||
$resumeData['work_experiences'] = $resumeData['work_experiences'] ?? [];
|
||||
$resumeData['education'] = $resumeData['education'] ?? [];
|
||||
$resumeData['skills'] = $resumeData['skills'] ?? [];
|
||||
$resumeData['languages'] = $resumeData['languages'] ?? [];
|
||||
$resumeData['certifications'] = $resumeData['certifications'] ?? [];
|
||||
$resumeData['projects'] = $resumeData['projects'] ?? [];
|
||||
$resumeData['references'] = $resumeData['references'] ?? [];
|
||||
$resumeData['custom_sections'] = $resumeData['custom_sections'] ?? [];
|
||||
$resumeData['settings'] = $resumeData['settings'] ?? $this->getDefaultSettings();
|
||||
|
||||
$resume = Resume::create($resumeData);
|
||||
|
||||
logger()->info('Resume created', [
|
||||
'resume_id' => $resume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
'title' => $resume->title,
|
||||
]);
|
||||
|
||||
return $resume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing resume
|
||||
*/
|
||||
public function updateResume(Resume $resume, array $resumeData): Resume
|
||||
{
|
||||
// Update completion status based on content
|
||||
if ($this->shouldMarkAsCompleted($resumeData)) {
|
||||
$resumeData['is_completed'] = true;
|
||||
}
|
||||
|
||||
$resume->update($resumeData);
|
||||
|
||||
logger()->info('Resume updated', [
|
||||
'resume_id' => $resume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
'completion' => $resume->completion_percentage,
|
||||
]);
|
||||
|
||||
return $resume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a resume
|
||||
*/
|
||||
public function deleteResume(Resume $resume): bool
|
||||
{
|
||||
$resumeId = $resume->id;
|
||||
$userId = $resume->user_id;
|
||||
|
||||
$deleted = $resume->delete();
|
||||
|
||||
if ($deleted) {
|
||||
logger()->info('Resume deleted', [
|
||||
'resume_id' => $resumeId,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a resume
|
||||
*/
|
||||
public function duplicateResume(Resume $resume, string $newTitle): Resume
|
||||
{
|
||||
$resumeData = $resume->toArray();
|
||||
|
||||
// Remove unique identifiers
|
||||
unset($resumeData['id'], $resumeData['slug'], $resumeData['public_url']);
|
||||
|
||||
// Set new title and reset status
|
||||
$resumeData['title'] = $newTitle;
|
||||
$resumeData['is_public'] = false;
|
||||
$resumeData['created_at'] = now();
|
||||
$resumeData['updated_at'] = now();
|
||||
|
||||
$newResume = Resume::create($resumeData);
|
||||
|
||||
logger()->info('Resume duplicated', [
|
||||
'original_resume_id' => $resume->id,
|
||||
'new_resume_id' => $newResume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
]);
|
||||
|
||||
return $newResume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PDF for a resume
|
||||
*/
|
||||
public function generatePdf(Resume $resume): Response
|
||||
{
|
||||
$template = $this->getTemplate($resume->template_id ?? 'professional');
|
||||
|
||||
$pdf = Pdf::loadView('pdf.resume.' . $template['id'], [
|
||||
'resume' => $resume,
|
||||
'template' => $template
|
||||
]);
|
||||
|
||||
$fileName = str()->slug($resume->title) . '-resume.pdf';
|
||||
|
||||
logger()->info('PDF generated', [
|
||||
'resume_id' => $resume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
'template' => $template['id'],
|
||||
]);
|
||||
|
||||
return $pdf->download($fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make resume public
|
||||
*/
|
||||
public function makePublic(Resume $resume): Resume
|
||||
{
|
||||
if (!$resume->public_url) {
|
||||
$resume->public_url = $resume->generatePublicUrl();
|
||||
}
|
||||
|
||||
$resume->update(['is_public' => true]);
|
||||
|
||||
logger()->info('Resume made public', [
|
||||
'resume_id' => $resume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
'public_url' => $resume->public_url,
|
||||
]);
|
||||
|
||||
return $resume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make resume private
|
||||
*/
|
||||
public function makePrivate(Resume $resume): Resume
|
||||
{
|
||||
$resume->update(['is_public' => false]);
|
||||
|
||||
logger()->info('Resume made private', [
|
||||
'resume_id' => $resume->id,
|
||||
'user_id' => $resume->user_id,
|
||||
]);
|
||||
|
||||
return $resume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available templates
|
||||
*/
|
||||
public function getAvailableTemplates(): array
|
||||
{
|
||||
return $this->templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific template
|
||||
*/
|
||||
public function getTemplate(string $templateId): array
|
||||
{
|
||||
return $this->templates[$templateId] ?? $this->templates['professional'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resume analytics
|
||||
*/
|
||||
public function getResumeAnalytics(Resume $resume): array
|
||||
{
|
||||
return [
|
||||
'completion_percentage' => $resume->completion_percentage,
|
||||
'sections_completed' => $this->getCompletedSections($resume),
|
||||
'total_sections' => 7,
|
||||
'word_count' => $this->calculateWordCount($resume),
|
||||
'last_updated' => $resume->updated_at,
|
||||
'views' => 0, // Implement view tracking if needed
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get completed sections count
|
||||
*/
|
||||
protected function getCompletedSections(Resume $resume): int
|
||||
{
|
||||
$sections = ['personal_info', 'professional_summary', 'work_experiences',
|
||||
'education', 'skills', 'languages', 'certifications'];
|
||||
|
||||
$completed = 0;
|
||||
foreach ($sections as $section) {
|
||||
if ($resume->isSectionCompleted($section)) {
|
||||
$completed++;
|
||||
}
|
||||
}
|
||||
|
||||
return $completed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total word count in resume
|
||||
*/
|
||||
protected function calculateWordCount(Resume $resume): int
|
||||
{
|
||||
$wordCount = 0;
|
||||
|
||||
// Professional summary
|
||||
if ($resume->professional_summary) {
|
||||
$wordCount += str_word_count(strip_tags($resume->professional_summary));
|
||||
}
|
||||
|
||||
// Work experiences
|
||||
if ($resume->work_experiences) {
|
||||
foreach ($resume->work_experiences as $experience) {
|
||||
$wordCount += str_word_count(strip_tags($experience['description'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
// Education
|
||||
if ($resume->education) {
|
||||
foreach ($resume->education as $education) {
|
||||
$wordCount += str_word_count(strip_tags($education['description'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
// Projects
|
||||
if ($resume->projects) {
|
||||
foreach ($resume->projects as $project) {
|
||||
$wordCount += str_word_count(strip_tags($project['description'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
return $wordCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if resume should be marked as completed
|
||||
*/
|
||||
protected function shouldMarkAsCompleted(array $resumeData): bool
|
||||
{
|
||||
$requiredSections = ['personal_info', 'work_experiences', 'education'];
|
||||
|
||||
foreach ($requiredSections as $section) {
|
||||
if (empty($resumeData[$section])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default resume settings
|
||||
*/
|
||||
protected function getDefaultSettings(): array
|
||||
{
|
||||
return [
|
||||
'theme' => 'light',
|
||||
'font_family' => 'Roboto',
|
||||
'font_size' => 14,
|
||||
'color_scheme' => 'blue',
|
||||
'show_photo' => false,
|
||||
'show_references' => true,
|
||||
'show_certifications' => true,
|
||||
'show_projects' => true,
|
||||
'section_order' => [
|
||||
'personal_info',
|
||||
'professional_summary',
|
||||
'work_experiences',
|
||||
'education',
|
||||
'skills',
|
||||
'languages',
|
||||
'certifications',
|
||||
'projects',
|
||||
'references'
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resume statistics for user
|
||||
*/
|
||||
public function getUserResumeStats(int $userId): array
|
||||
{
|
||||
$resumes = $this->getUserResumes($userId);
|
||||
|
||||
return [
|
||||
'total_resumes' => $resumes->count(),
|
||||
'completed_resumes' => $resumes->where('is_completed', true)->count(),
|
||||
'public_resumes' => $resumes->where('is_public', true)->count(),
|
||||
'avg_completion' => $resumes->avg('completion_percentage') ?? 0,
|
||||
'last_updated' => $resumes->max('updated_at'),
|
||||
];
|
||||
}
|
||||
}
|
||||
400
app/Services/SecurityService.php
Normal file
400
app/Services/SecurityService.php
Normal file
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Security Service
|
||||
* Professional Resume Builder - Enterprise Security Service
|
||||
*
|
||||
* @author David Valera Melendez <david@valera-melendez.de>
|
||||
* @created 2025-08-08
|
||||
* @location Made in Germany 🇩🇪
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Interfaces\UserRepositoryInterface;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Security Service
|
||||
* Handles security-related operations and monitoring
|
||||
*/
|
||||
class SecurityService
|
||||
{
|
||||
/**
|
||||
* User repository instance
|
||||
*/
|
||||
private UserRepositoryInterface $userRepository;
|
||||
|
||||
/**
|
||||
* Maximum failed attempts before account lockout
|
||||
*/
|
||||
private const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Account lockout duration in minutes
|
||||
*/
|
||||
private const LOCKOUT_DURATION = 15;
|
||||
|
||||
/**
|
||||
* Password reset token expiry in hours
|
||||
*/
|
||||
private const PASSWORD_RESET_EXPIRY = 24;
|
||||
|
||||
/**
|
||||
* Create a new service instance
|
||||
*/
|
||||
public function __construct(UserRepositoryInterface $userRepository)
|
||||
{
|
||||
$this->userRepository = $userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secure password reset token
|
||||
*
|
||||
* @param User $user
|
||||
* @return string
|
||||
*/
|
||||
public function generatePasswordResetToken(User $user): string
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiry = now()->addHours(self::PASSWORD_RESET_EXPIRY);
|
||||
|
||||
// Store token in cache with expiry
|
||||
Cache::put(
|
||||
"password_reset:{$user->id}:{$token}",
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'expires_at' => $expiry,
|
||||
'created_at' => now()
|
||||
],
|
||||
$expiry
|
||||
);
|
||||
|
||||
Log::info('Password reset token generated', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'expires_at' => $expiry,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password reset token
|
||||
*
|
||||
* @param string $token
|
||||
* @param int $userId
|
||||
* @return bool
|
||||
*/
|
||||
public function validatePasswordResetToken(string $token, int $userId): bool
|
||||
{
|
||||
$cacheKey = "password_reset:{$userId}:{$token}";
|
||||
$tokenData = Cache::get($cacheKey);
|
||||
|
||||
if (!$tokenData) {
|
||||
Log::warning('Invalid password reset token used', [
|
||||
'user_id' => $userId,
|
||||
'token' => substr($token, 0, 8) . '...',
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($tokenData['user_id'] !== $userId) {
|
||||
Log::warning('Password reset token user mismatch', [
|
||||
'expected_user_id' => $userId,
|
||||
'token_user_id' => $tokenData['user_id'],
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user password using token
|
||||
*
|
||||
* @param string $token
|
||||
* @param int $userId
|
||||
* @param string $newPassword
|
||||
* @return bool
|
||||
* @throws Exception
|
||||
*/
|
||||
public function resetPassword(string $token, int $userId, string $newPassword): bool
|
||||
{
|
||||
try {
|
||||
if (!$this->validatePasswordResetToken($token, $userId)) {
|
||||
throw new Exception('Invalid or expired password reset token');
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findById($userId);
|
||||
if (!$user) {
|
||||
throw new Exception('User not found');
|
||||
}
|
||||
|
||||
// Update password
|
||||
$hashedPassword = Hash::make($newPassword);
|
||||
$updateData = [
|
||||
'password' => $hashedPassword,
|
||||
'password_updated_at' => now(),
|
||||
'login_attempts' => 0,
|
||||
'account_locked_until' => null
|
||||
];
|
||||
|
||||
$success = $this->userRepository->update($userId, $updateData);
|
||||
|
||||
if ($success) {
|
||||
// Remove used token
|
||||
$cacheKey = "password_reset:{$userId}:{$token}";
|
||||
Cache::forget($cacheKey);
|
||||
|
||||
Log::info('Password reset completed successfully', [
|
||||
'user_id' => $userId,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
}
|
||||
|
||||
return $success;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Password reset failed', [
|
||||
'error_message' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user account is secure
|
||||
*
|
||||
* @param User $user
|
||||
* @return array
|
||||
*/
|
||||
public function checkAccountSecurity(User $user): array
|
||||
{
|
||||
$securityChecks = [
|
||||
'password_strength' => $this->checkPasswordStrength($user),
|
||||
'two_factor_enabled' => $user->two_factor_secret !== null,
|
||||
'email_verified' => $user->email_verified_at !== null,
|
||||
'recent_password_change' => $this->checkRecentPasswordChange($user),
|
||||
'suspicious_activity' => $this->checkSuspiciousActivity($user),
|
||||
'account_locked' => $this->userRepository->isAccountLocked($user->email)
|
||||
];
|
||||
|
||||
$securityScore = $this->calculateSecurityScore($securityChecks);
|
||||
|
||||
return [
|
||||
'security_score' => $securityScore,
|
||||
'checks' => $securityChecks,
|
||||
'recommendations' => $this->getSecurityRecommendations($securityChecks)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security event
|
||||
*
|
||||
* @param string $event
|
||||
* @param User $user
|
||||
* @param array $details
|
||||
*/
|
||||
public function logSecurityEvent(string $event, User $user, array $details = []): void
|
||||
{
|
||||
$logData = array_merge([
|
||||
'event' => $event,
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'timestamp' => now()->toISOString()
|
||||
], $details);
|
||||
|
||||
Log::warning("Security Event: {$event}", $logData);
|
||||
|
||||
// Store in security events table (implement if needed)
|
||||
// SecurityEvent::create($logData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate two-factor authentication secret
|
||||
*
|
||||
* @param User $user
|
||||
* @return string
|
||||
*/
|
||||
public function generateTwoFactorSecret(User $user): string
|
||||
{
|
||||
$secret = bin2hex(random_bytes(16));
|
||||
|
||||
$this->userRepository->update($user->id, [
|
||||
'two_factor_secret' => $secret,
|
||||
'two_factor_recovery_codes' => $this->generateRecoveryCodes()
|
||||
]);
|
||||
|
||||
Log::info('Two-factor authentication enabled', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify two-factor authentication code
|
||||
*
|
||||
* @param User $user
|
||||
* @param string $code
|
||||
* @return bool
|
||||
*/
|
||||
public function verifyTwoFactorCode(User $user, string $code): bool
|
||||
{
|
||||
if (!$user->two_factor_secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Implement TOTP verification logic here
|
||||
// This is a simplified version
|
||||
$isValid = $this->validateTOTP($user->two_factor_secret, $code);
|
||||
|
||||
if ($isValid) {
|
||||
Log::info('Two-factor authentication verified', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
} else {
|
||||
Log::warning('Invalid two-factor authentication code', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip()
|
||||
]);
|
||||
}
|
||||
|
||||
return $isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check password strength
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
private function checkPasswordStrength(User $user): bool
|
||||
{
|
||||
// This is a simplified check - implement proper password strength validation
|
||||
return $user->password_updated_at &&
|
||||
$user->password_updated_at->gt(now()->subMonths(6));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if password was changed recently
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
private function checkRecentPasswordChange(User $user): bool
|
||||
{
|
||||
return $user->password_updated_at &&
|
||||
$user->password_updated_at->gt(now()->subMonths(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for suspicious activity
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
private function checkSuspiciousActivity(User $user): bool
|
||||
{
|
||||
// Check failed login attempts
|
||||
return $user->login_attempts > 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate security score
|
||||
*
|
||||
* @param array $checks
|
||||
* @return int
|
||||
*/
|
||||
private function calculateSecurityScore(array $checks): int
|
||||
{
|
||||
$score = 0;
|
||||
$totalChecks = count($checks);
|
||||
|
||||
foreach ($checks as $check) {
|
||||
if ($check === true) {
|
||||
$score++;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) (($score / $totalChecks) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security recommendations
|
||||
*
|
||||
* @param array $checks
|
||||
* @return array
|
||||
*/
|
||||
private function getSecurityRecommendations(array $checks): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
if (!$checks['two_factor_enabled']) {
|
||||
$recommendations[] = 'Enable two-factor authentication for enhanced security';
|
||||
}
|
||||
|
||||
if (!$checks['email_verified']) {
|
||||
$recommendations[] = 'Verify your email address';
|
||||
}
|
||||
|
||||
if (!$checks['recent_password_change']) {
|
||||
$recommendations[] = 'Consider changing your password regularly';
|
||||
}
|
||||
|
||||
if ($checks['suspicious_activity']) {
|
||||
$recommendations[] = 'Review recent login activity for suspicious behavior';
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate recovery codes
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function generateRecoveryCodes(): array
|
||||
{
|
||||
$codes = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$codes[] = strtoupper(bin2hex(random_bytes(4)));
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate TOTP code (simplified implementation)
|
||||
*
|
||||
* @param string $secret
|
||||
* @param string $code
|
||||
* @return bool
|
||||
*/
|
||||
private function validateTOTP(string $secret, string $code): bool
|
||||
{
|
||||
// This is a placeholder - implement proper TOTP validation
|
||||
// You would typically use a library like spomky-labs/otphp
|
||||
return strlen($code) === 6 && is_numeric($code);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user