diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 50aca1f..e3682be 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -17,11 +17,13 @@ use Fleetbase\Models\VerificationCode; use Fleetbase\Notifications\UserForgotPassword; use Fleetbase\Support\Auth; +use Fleetbase\Support\TwoFactorAuth; use Fleetbase\Support\Utils; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Str; +use Laravel\Sanctum\PersonalAccessToken; class AuthController extends Controller { @@ -32,14 +34,43 @@ class AuthController extends Controller */ public function login(LoginRequest $request) { - $email = $request->input('email'); - $password = $request->input('password'); - $user = User::where('email', $email)->first(); + $identity = $request->input('identity'); + $password = $request->input('password'); + $authToken = $request->input('authToken'); + + // if attempting to authenticate with auth token validate it first against database and respond with it + if ($authToken) { + $personalAccessToken = PersonalAccessToken::findToken($authToken); + + if ($personalAccessToken) { + return response()->json(['token' => $authToken]); + } + } + + // Find the user using the identity provided + $user = User::where(function ($query) use ($identity) { + $query->where('email', $identity)->orWhere('phone', $identity); + })->first(); if (!$user) { - return response()->error('No user found by this email.', 401, ['code' => 'no_user']); + return response()->error('No user found by the provided identity.', 401, ['code' => 'no_user']); } + // Check if 2FA enabled + if (TwoFactorAuth::isEnabled($user)) { + $twoFaSession = TwoFactorAuth::start($user); + + return response()->json([ + 'twoFaSession' => $twoFaSession, + 'isEnabled' => true, + ]); + } + + // Create token + $token = $user->createToken($user->uuid); + + return response()->json(['token' => $token->plainTextToken]); + if (Auth::isInvalidPassword($password, $user->password)) { return response()->error('Authentication failed using password provided.', 401, ['code' => 'invalid_password']); } @@ -418,8 +449,7 @@ public function signUp(SignUpRequest $request) $companyDetails = $request->input('company'); $newUser = Auth::register($userDetails, $companyDetails); - - $token = $newUser->createToken($request->ip()); + $token = $newUser->createToken($newUser->uuid); return response()->json(['token' => $token->plainTextToken]); } diff --git a/src/Http/Controllers/Internal/v1/CompanyController.php b/src/Http/Controllers/Internal/v1/CompanyController.php index 4d53d31..a91b3f7 100644 --- a/src/Http/Controllers/Internal/v1/CompanyController.php +++ b/src/Http/Controllers/Internal/v1/CompanyController.php @@ -6,6 +6,9 @@ use Fleetbase\Http\Resources\Organization; use Fleetbase\Models\Company; use Fleetbase\Models\Invite; +use Fleetbase\Support\Auth; +use Fleetbase\Support\TwoFactorAuth; +use Illuminate\Http\Request; use Illuminate\Support\Str; class CompanyController extends FleetbaseController @@ -39,4 +42,46 @@ public function findCompany(string $id) return new Organization($company); } + + /** + * Get the current organization's two factor authentication settings. + * + * @return \Illuminate\Http\Response + */ + public function getTwoFactorSettings() + { + $company = Auth::getCompany(); + + if (!$company) { + return response()->error('No company session found', 401); + } + + $twoFaSettings = TwoFactorAuth::getTwoFaSettingsForCompany($company); + + return response()->json($twoFaSettings->value); + } + + + /** + * Save the two factor authentication settings for the current company. + * + * @param \Illuminate\Http\Request $request The HTTP request. + * + * @return \Illuminate\Http\Response + */ + public function saveTwoFactorSettings(Request $request) + { + $twoFaSettings = $request->array('twoFaSettings'); + $company = Auth::getCompany(); + + if (!$company) { + return response()->error('No company session found', 401); + } + if (isset($twoFaSettings['enabled']) && $twoFaSettings['enabled'] === false) { + $twoFaSettings['enforced'] = false; + } + TwoFactorAuth::saveTwoFaSettingsForCompany($company, $twoFaSettings); + + return response()->json(['message' => 'Two-Factor Authentication saved successfully']); + } } diff --git a/src/Http/Controllers/Internal/v1/TwoFaController.php b/src/Http/Controllers/Internal/v1/TwoFaController.php new file mode 100644 index 0000000..41f262c --- /dev/null +++ b/src/Http/Controllers/Internal/v1/TwoFaController.php @@ -0,0 +1,166 @@ +array('twoFaSettings'); + if (isset($twoFaSettings['enabled']) && $twoFaSettings['enabled'] === false) { + $twoFaSettings['enforced'] = false; + } + $settings = TwoFactorAuth::configureTwoFaSettings($twoFaSettings); + + return response()->json($settings->value); + } + + /** + * Get Two-Factor Authentication system wide settings. + * + * @return \Illuminate\Http\Response + */ + public function getSystemConfig() + { + $settings = TwoFactorAuth::getTwoFaConfiguration(); + + return response()->json($settings->value); + } + + /** + * Check Two-Factor Authentication status for a given user identity. + * + * @return \Illuminate\Http\Response + */ + public function checkTwoFactor(Request $request) + { + $identity = $request->input('identity'); + $twoFaSession = TwoFactorAuth::createTwoFaSessionIfEnabled($identity); + $isTwoFaEnabled = $twoFaSession !== null; + + return response()->json([ + 'twoFaSession' => $twoFaSession, + 'isTwoFaEnabled' => $isTwoFaEnabled, + ]); + } + + /** + * Verify Two-Factor Authentication code. + * + * @return \Illuminate\Http\Response + */ + public function validateSession(TwoFaValidationRequest $request) + { + $token = $request->input('token'); + $identity = $request->input('identity'); + $clientToken = $request->input('clientToken'); + + try { + $validClientToken = TwoFactorAuth::getClientSessionTokenFromTwoFaSession($token, $identity, $clientToken); + + return response()->json([ + 'clientToken' => $validClientToken, + 'expired' => false, + ]); + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + + if (Str::contains($errorMessage, ['2FA Verification', 'expired'])) { + return response()->json([ + 'expired' => true, + ]); + } + + return response()->error($errorMessage); + } + } + + /** + * Verify Two-Factor Authentication code. + * + * @return \Illuminate\Http\Response + */ + public function verifyCode(Request $request) + { + $code = $request->input('code'); + $token = $request->input('token'); + $clientToken = $request->input('clientToken'); + + try { + $authToken = TwoFactorAuth::verifyCode($code, $token, $clientToken); + + return response()->json([ + 'authToken' => $authToken, + ]); + } catch (\Exception $e) { + return response()->error($e->getMessage()); + } + } + + /** + * Resend Two-Factor Authentication verification code. + * + * @return \Illuminate\Http\Response + */ + public function resendCode(Request $request) + { + $identity = $request->input('identity'); + $token = $request->input('token'); + + try { + $clientToken = TwoFactorAuth::resendCode($identity, $token); + + return response()->json([ + 'clientToken' => $clientToken, + ]); + } catch (\Exception $e) { + return response()->error($e->getMessage()); + } + } + + /** + * Invalidate the current two-factor session. + * + * @return \Illuminate\Http\Response + */ + public function invalidateSession(Request $request) + { + $identity = $request->input('identity'); + $token = $request->input('token'); + + try { + $ok = TwoFactorAuth::forgetTwoFaSession($token, $identity); + + return response()->json([ + 'ok' => $ok, + ]); + } catch (\Exception $e) { + return response()->json(['ok' => false]); + } + } + + public function shouldEnforce(Request $request) + { + $user = $request->user(); + $enforceTwoFa = TwoFactorAuth::shouldEnforce($user); + + return response()->json([ + 'shouldEnforce' => $enforceTwoFa, + ]); + } +} diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 108b951..c76cbf1 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -10,6 +10,7 @@ use Fleetbase\Http\Requests\Internal\InviteUserRequest; use Fleetbase\Http\Requests\Internal\ResendUserInvite; use Fleetbase\Http\Requests\Internal\UpdatePasswordRequest; +use Fleetbase\Http\Requests\Internal\ValidatePasswordRequest; use Fleetbase\Models\Company; use Fleetbase\Models\CompanyUser; use Fleetbase\Models\Invite; @@ -17,10 +18,12 @@ use Fleetbase\Notifications\UserAcceptedCompanyInvite; use Fleetbase\Notifications\UserInvited; use Fleetbase\Support\NotificationRegistry; +use Fleetbase\Support\TwoFactorAuth; use Fleetbase\Support\Utils; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Maatwebsite\Excel\Facades\Excel; @@ -53,6 +56,43 @@ public function current(Request $request) ); } + /** + * Get the current user's two factor authentication settings. + * + * @return \Illuminate\Http\Response + */ + public function getTwoFactorSettings(Request $request) + { + $user = $request->user(); + + if (!$user) { + return response()->error('No user session found', 401); + } + + $twoFaSettings = TwoFactorAuth::getTwoFaSettingsForUser($user); + + return response()->json($twoFaSettings->value); + } + + /** + * Save the current user's two factor authentication settings. + * + * @return \Illuminate\Http\Response + */ + public function saveTwoFactorSettings(Request $request) + { + $twoFaSettings = $request->array('twoFaSettings'); + $user = $request->user(); + + if (!$user) { + return response()->error('No user session found', 401); + } + + $twoFaSettings = TwoFactorAuth::saveTwoFaSettingsForUser($user, $twoFaSettings); + + return response()->json($twoFaSettings->value); + } + /** * Creates a user, adds the user to company and sends an email to user about being added. * @@ -367,4 +407,48 @@ public static function getWithDriver($id, Request $request) return response()->json(['user' => $user]); } + + /** + * Validate the user's current password. + * + * @param \Fleetbase\Http\Requests\Internal\ValidatePasswordRequest $request + * @return \Illuminate\Http\Response + */ + public function validatePassword(ValidatePasswordRequest $request) + { + $user = $request->user(); + $currentPassword = $request->input('current_password'); + $confirmPassword = $request->input('confirm_password'); + + if (!$user || !$user->checkPassword($currentPassword)) { + return response()->error('Invalid current password', 422); + } + + if ($currentPassword !== $confirmPassword) { + return response()->error('Password is not matching'); + } + + return response()->json(['status' => 'ok']); + } + + /** + * Change the user's password. + * + * @param \Fleetbase\Http\Requests\Internal\UpdatePasswordRequest $request + * @return \Illuminate\Http\Response + */ + public function changeUserPassword(UpdatePasswordRequest $request) + { + $user = $request->user(); + $newPassword = $request->input('password'); + $newConfirmPassword = $request->input('password_confirmation'); + + if ($newPassword !== $newConfirmPassword) { + return response()->error('Password is not matching'); + } + + $user->changePassword($newPassword); + + return response()->json(['status' => 'ok']); + } } diff --git a/src/Http/Requests/Internal/ValidatePasswordRequest.php b/src/Http/Requests/Internal/ValidatePasswordRequest.php new file mode 100644 index 0000000..54d8652 --- /dev/null +++ b/src/Http/Requests/Internal/ValidatePasswordRequest.php @@ -0,0 +1,45 @@ + 'required|string|min:6', + 'confirm_password' => 'required|string|min:6|same:current_password', + ]; + } + + /** + * Get the error messages for the defined validation rules. + * + * @return array + */ + public function messages() + { + return [ + 'current_password.required' => 'The current password is required.', + 'current_password.string' => 'The current password must be a string.', + 'current_password.min' => 'The current password must be at least 8 characters.', + ]; + } +} diff --git a/src/Http/Requests/LoginRequest.php b/src/Http/Requests/LoginRequest.php index 78212f6..7557bba 100644 --- a/src/Http/Requests/LoginRequest.php +++ b/src/Http/Requests/LoginRequest.php @@ -40,7 +40,7 @@ protected function failedValidation(Validator $validator) public function rules() { return [ - 'email' => 'required|email|exists:users,email', + 'identity' => 'required|email|exists:users,email', 'password' => 'required', ]; } @@ -53,9 +53,9 @@ public function rules() public function messages() { return [ - 'email.required' => 'A email is required', - 'email.exists' => 'No user found by this email', - 'email.email' => 'Email used is invalid', + 'identity.required' => 'A email is required', + 'identity.exists' => 'No user found by this email', + 'identity.email' => 'Email used is invalid', 'password.required' => 'A password is required', ]; } diff --git a/src/Http/Requests/TwoFaValidationRequest.php b/src/Http/Requests/TwoFaValidationRequest.php new file mode 100644 index 0000000..944b087 --- /dev/null +++ b/src/Http/Requests/TwoFaValidationRequest.php @@ -0,0 +1,63 @@ +errors(); + $response = [ + 'errors' => [$errors->first()], + ]; + // if more than one error display the others + if ($errors->count() > 1) { + $response['errors'] = collect($errors->all()) + ->values() + ->toArray(); + } + + return response()->json($response, 422); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'token' => 'required', + 'identity' => 'required|email|exists:users,email', + ]; + } + + /** + * Get the error messages for the defined validation rules. + * + * @return array + */ + public function messages() + { + return [ + 'identity.required' => 'Email or phone number is required', + 'identity.exists' => 'No user found by this email', + 'identity.email' => 'Email used is invalid', + 'identity.phone' => 'Phone Number used is invalid', + 'token.required' => 'A two factor session token is required', + ]; + } +} diff --git a/src/Mail/VerifyEmail.php b/src/Mail/VerifyEmail.php index 13193bc..5a7a030 100644 --- a/src/Mail/VerifyEmail.php +++ b/src/Mail/VerifyEmail.php @@ -4,10 +4,11 @@ use Fleetbase\Models\VerificationCode; use Illuminate\Bus\Queueable; -// use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Eloquent\Model; use Illuminate\Mail\Mailable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\HtmlString; class VerifyEmail extends Mailable { @@ -16,17 +17,59 @@ class VerifyEmail extends Mailable public string $verifyCode; public string $greeting; + public string $messageSubject; + public array $lines = []; /** * Create a new message instance. * * @return void */ - public function __construct($verifyCode, $subject = null, $user = null) + public function __construct($verificationCode, string $subject = null, array $lines = [], Model $user = null) { - $this->verifyCode = $verifyCode instanceof VerificationCode ? $verifyCode->code : $verifyCode; - $this->subject = $subject ?? ($this->verifyCode . ' is your Fleetbase verification code'); - $this->greeting = ($user && isset($user->name)) ? 'Hello, ' . $user->name . '!' : 'Hello!'; + $this->setVerificationCode($verificationCode); + $this->setSubject($subject); + $this->setEmailLines($lines); + $this->setGreeting($user); + } + + public function setVerificationCode($verificationCode): void + { + if ($verificationCode instanceof VerificationCode) { + $this->verifyCode = $verificationCode->code; + } else { + $this->verifyCode = $verificationCode; + } + } + + public function setSubject(?string $subject): void + { + if (is_string($subject) && !empty($subject)) { + $this->messageSubject = $subject; + } else { + $this->messageSubject = $this->verifyCode . ' is your ' . config('app.name') . ' verification code'; + } + } + + public function setEmailLines(array $lines = []): void + { + if (!empty($lines)) { + $this->lines = $lines; + } else { + $this->lines = [ + 'Welcome to ' . config('app.name') . ', use the code below to verify your email address and complete registration to ' . config('app.name') . '.', + new HtmlString('

Your verification code: ' . $this->verifyCode . '


'), + ]; + } + } + + public function setGreeting(Model $user): void + { + if ($user && isset($user->name)) { + $this->greeting = 'Hello, ' . $user->name . '!'; + } else { + $this->greeting = 'Hello!'; + } } /** @@ -37,12 +80,10 @@ public function __construct($verifyCode, $subject = null, $user = null) public function build() { return $this - ->subject($this->subject) + ->subject($this->messageSubject) ->html((new MailMessage()) ->greeting($this->greeting) - ->line('Welcome to Fleetbase, use the code below to verify your email address and complete registration to Fleetbase.') - ->line('') - ->line('Your verification code: ' . $this->verifyCode) + ->lines($this->lines) ->render() ); } diff --git a/src/Models/Company.php b/src/Models/Company.php index 286323b..e56eaaf 100644 --- a/src/Models/Company.php +++ b/src/Models/Company.php @@ -3,6 +3,7 @@ namespace Fleetbase\Models; use Fleetbase\Casts\Json; +use Fleetbase\FleetOps\Models\Driver; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasOptionsAttributes; use Fleetbase\Traits\HasPublicId; diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 6a0c21f..f87c9c5 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -3,10 +3,18 @@ namespace Fleetbase\Models; use Fleetbase\Casts\Json; +use Fleetbase\Support\Utils; +use Fleetbase\Traits\Filterable; +use Fleetbase\Traits\HasApiModelBehavior; +use Fleetbase\Traits\Searchable; use Illuminate\Database\Eloquent\Model as EloquentModel; class Setting extends EloquentModel { + use HasApiModelBehavior; + use Searchable; + use Filterable; + /** * Create a new instance of the model. * @@ -120,7 +128,7 @@ public static function system($key, $defaultValue = null) * * @param string $key */ - public static function configureSystem($key, $value = null) + public static function configureSystem($key, $value = null): ?Setting { return static::configure('system.' . $key, $value); } @@ -130,7 +138,7 @@ public static function configureSystem($key, $value = null) * * @param string $key */ - public static function configure($key, $value = null) + public static function configure($key, $value = null): ?Setting { return static::updateOrCreate( ['key' => $key], @@ -157,6 +165,16 @@ public static function lookup(string $key, $defaultValue = null) return data_get($setting, 'value', $defaultValue); } + /** + * Get a settting record by key. + * + * @return \Fleetbase\Models\Setting|null + */ + public static function getByKey(string $key) + { + return static::where('key', $key)->first(); + } + public static function getBranding() { $brandingSettings = ['id' => 1, 'uuid' => 1, 'icon_url' => null, 'logo_url' => null]; @@ -189,4 +207,14 @@ public static function getBranding() return $brandingSettings; } + + public function getValue(string $key, $defaultValue = null) + { + return data_get($this->value, $key, $defaultValue); + } + + public function getBoolean(string $key) + { + return Utils::castBoolean($this->getValue($key, false)); + } } diff --git a/src/Models/User.php b/src/Models/User.php index 17e1c36..e41bd46 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -423,6 +423,11 @@ public function changePassword($newPassword): User return $this; } + public function checkPassword(string $password): bool + { + return Hash::check($password, $this->password); + } + /** * Deactivate this user. */ @@ -585,6 +590,18 @@ public function sendInviteFromCompany(Company $company = null): bool return true; } + public function getIdentity(): ?string + { + $email = data_get($this, 'email'); + $phone = data_get($this, 'phone'); + + if ($email) { + return $email; + } + + return $phone; + } + /** * Check if the user is verified. * diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index c315df0..0294b17 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -92,32 +92,33 @@ public static function generateFor($subject = null, $for = 'general_verification } /** static method to generate code for email verification */ - public static function generateEmailVerificationFor($subject, $for = 'email_verification', \Closure $messageCallback = null, $meta = []) + public static function generateEmailVerificationFor($subject, $for = 'email_verification', \Closure $messageCallback = null, \Closure $linesCallback = null, $meta = [], $expireAfter = null) { $verifyCode = static::generateFor($subject, $for, false); - $verifyCode->expires_at = Carbon::now()->addHour(); + $verifyCode->expires_at = $expireAfter === null ? Carbon::now()->addHour() : $expireAfter; $verifyCode->meta = $meta; $verifyCode->save(); - $emailSubject = $messageCallback ? $messageCallback($verifyCode) : null; + $emailSubject = is_callable($messageCallback) ? $messageCallback($verifyCode) : null; + $emailLines = is_callable($linesCallback) ? $linesCallback($verifyCode) : []; if (isset($subject->email)) { - Mail::to($subject)->send(new VerifyEmail($verifyCode, $emailSubject, $subject)); + Mail::to($subject)->send(new VerifyEmail($verifyCode, $emailSubject, $emailLines, $subject)); } return $verifyCode; } /** static method to generate code for phone verification */ - public static function generateSmsVerificationFor($subject, $for = 'phone_verification', \Closure $messageCallback = null, $meta = []) + public static function generateSmsVerificationFor($subject, $for = 'phone_verification', \Closure $messageCallback = null, $meta = [], $expireAfter = null) { $verifyCode = static::generateFor($subject, $for, false); - $verifyCode->expires_at = Carbon::now()->addHour(); + $verifyCode->expires_at = $expireAfter === null ? Carbon::now()->addHour() : $expireAfter; $verifyCode->meta = $meta; $verifyCode->save(); if ($subject->phone) { - Twilio::message($subject->phone, $messageCallback ? $messageCallback($verifyCode) : "Your Fleetbase verification code is {$verifyCode->code}"); + Twilio::message($subject->phone, $messageCallback ? $messageCallback($verifyCode) : 'Your ' . config('app.name') . ' verification code is ' . $verifyCode->code); } return $verifyCode; diff --git a/src/Notifications/UserForgotPassword.php b/src/Notifications/UserForgotPassword.php index d583a48..0910da5 100644 --- a/src/Notifications/UserForgotPassword.php +++ b/src/Notifications/UserForgotPassword.php @@ -52,7 +52,7 @@ public function via($notifiable) public function toMail($notifiable) { return (new MailMessage()) - ->subject('Your password reset link for Fleetbase') + ->subject('Your password reset link for ' . config('app.name')) ->greeting('Hello, ' . $notifiable->name) ->line('Looks like you (or someone phishy) has requested to reset your password. If you did not request a password reset link, ignore this email. If you have indeed forgot your password click the button below to reset your password using the code provided below.') ->line('Your password reset code: ' . $this->verificationCode->code) diff --git a/src/Support/TwoFactorAuth.php b/src/Support/TwoFactorAuth.php new file mode 100644 index 0000000..c7ece2b --- /dev/null +++ b/src/Support/TwoFactorAuth.php @@ -0,0 +1,691 @@ + false, 'method' => 'email', 'enforced' => false]); + } + + return $twoFaSettings; + } + + /** + * Save Two-Factor Authentication settings for a specific subject (e.g., User, Company). + * + * @param \Illuminate\Database\Eloquent\Model $subject the subject model for which to save the settings + * @param array $twoFaSettings an array containing Two-Factor Authentication settings + * + * @return \Fleetbase\Models\Setting the saved Two-Factor Authentication settings + * + * @throws \Exception if the subject is not an instance of Model + */ + private static function saveTwoFaSettingsForSubject(Model $subject, array $twoFaSettings = []): Setting + { + if (!$subject instanceof Model) { + throw new \Exception('Subject must be a model.'); + } + + $type = Str::singular(Str::snake($subject->getTable(), '-')); // `user` - `company` + $key = $type . '.' . $subject->getKey() . '.2fa'; + + return Setting::configure($key, $twoFaSettings); + } + + /** + * Save Two-Factor Authentication settings for a user. + * + * @param \Fleetbase\Models\User $user the user for which to save the settings + * @param array $twoFaSettings an array containing Two-Factor Authentication settings + * + * @return \Fleetbase\Models\Setting the saved Two-Factor Authentication settings + */ + public static function saveTwoFaSettingsForUser(User $user, array $twoFaSettings = []): Setting + { + return static::saveTwoFaSettingsForSubject($user, $twoFaSettings); + } + + /** + * Save Two-Factor Authentication settings for a company. + * + * @param \Fleetbase\Models\Company $company the company for which to save the settings + * @param array $twoFaSettings an array containing Two-Factor Authentication settings + * + * @return \Fleetbase\Models\Setting the saved Two-Factor Authentication settings + */ + public static function saveTwoFaSettingsForCompany(Company $company, array $twoFaSettings = []): Setting + { + return static::saveTwoFaSettingsForSubject($company, $twoFaSettings); + } + + /** + * Get Two-Factor Authentication settings for a specific subject (e.g., User, Company). + * + * @param \Illuminate\Database\Eloquent\Model $subject the subject model for which to retrieve the settings + * + * @return \Fleetbase\Models\Setting the Two-Factor Authentication settings for the subject + * + * @throws \Exception if the subject is not an instance of Model + */ + private static function getTwoFaSettingsForSubject(Model $subject): Setting + { + if (!$subject instanceof Model) { + throw new \Exception('Subject must be a model.'); + } + + $type = Str::singular(Str::snake($subject->getTable(), '-')); // `user` - `company` + $key = $type . '.' . $subject->getKey() . '.2fa'; + + // Get the settings + $twoFaSettings = Setting::getByKey($key); + + if (!$twoFaSettings) { + $twoFaSettings = static::saveTwoFaSettingsForSubject($subject, ['enabled' => false, 'method' => 'email']); + } + + return $twoFaSettings; + } + + /** + * Get Two-Factor Authentication settings for a user. + * + * @param \Fleetbase\Models\User $user the user for which to retrieve the settings + * + * @return \Fleetbase\Models\Setting the Two-Factor Authentication settings for the user + */ + public static function getTwoFaSettingsForUser(User $user): Setting + { + return static::getTwoFaSettingsForSubject($user); + } + + /** + * Get Two-Factor Authentication settings for a company. + * + * @param \Fleetbase\Models\Company $company the company for which to retrieve the settings + * + * @return \Fleetbase\Models\Setting the Two-Factor Authentication settings for the company + */ + public static function getTwoFaSettingsForCompany(Company $company): Setting + { + return static::getTwoFaSettingsForSubject($company); + } + + /** + * Get a client session token from a Two-Factor Authentication session. + * + * @param string $token the Two-Factor Authentication token + * @param string $identity the user identity + * @param string|null $clientToken the optional client session token + * + * @return string the client session token + * + * @throws \Exception if Two-Factor Authentication is not enabled or the session is invalid + */ + public static function getClientSessionTokenFromTwoFaSession(string $token, string $identity, string $clientToken = null): string + { + // Get user from identity + $user = static::getUserFromIdentity($identity); + if (!$user) { + throw new \Exception('No user found for the identity provided.'); + } + + // Check if enabled 2FA + if (!self::isEnabled($user)) { + throw new \Exception('2FA Authentication is not enabled.'); + } + + // If a client session token is provided validate by fetching the verification code + // If a verification code exists then we just return the current valid client session + if ($clientToken) { + $verificationCode = static::getVerificationCodeFromClientToken($clientToken); + + // If verification code has expired throw exception + if ($verificationCode && $verificationCode->hasExpired()) { + static::forgetTwoFaSession($token, $identity); + throw new \Exception('2FA Verification code has expired.'); + } + + if ($verificationCode) { + return $clientToken; + } else { + static::forgetTwoFaSession($token, $identity); + throw new \Exception('2FA Verification code is invalid or has expired.'); + } + } + + // Decrypt two fa session token + $twoFaSessionKey = static::decryptSessionKey($token, $user->uuid); + + // Validate session key that it is valid and exists + if (static::isTwoFaSessionKeyValid($twoFaSessionKey, $user)) { + // Send the verification code then create a client session for the verification code and user + $verificationCode = static::sendVerificationCode($user); + $clientToken = static::createClientSessionToken($verificationCode); + + return $clientToken; + } + + throw new \Exception('2FA Authentication session is invalid'); + } + + /** + * Validate a Two-Factor Authentication session token. + * + * @param string $token the Two-Factor Authentication token + * @param string $identity the user identity + * @param string|null $clientToken the optional client session token + * + * @return bool true if the session token is valid, false otherwise + */ + public static function validateSessionToken(string $token, string $identity, string $clientToken = null): bool + { + try { + return is_string(static::getClientSessionTokenFromTwoFaSession($token, $identity, $clientToken)); + } catch (\Exception $e) { + return false; + } + + return false; + } + + /** + * Send a Two-Factor Authentication verification code to the user. + * + * @param \Fleetbase\Models\User $user the user to send the verification code to + * @param int $expiresAfter the expiration time for the verification code in seconds + * + * @return VerificationCode the generated verification code + * + * @throws \Exception if no phone number or email is available, or an invalid method is selected in settings + */ + public static function sendVerificationCode(User $user, int $expiresAfter = 61): VerificationCode + { + $twoFaSettings = static::getTwoFaSettingsForUser($user); + $method = $twoFaSettings->getValue('method', 'email'); + $expiresAfter = Carbon::now()->addSeconds($expiresAfter); + + // Create SMS and Email message callback + $messageCallback = function ($verificationCode) { + return $verificationCode->code . ' is your ' . config('app.name') . ' 2FA Code'; + }; + + if ($method === 'sms') { + // if user has no phone number throw error + if (!$user->phone) { + throw new \Exception('No phone number to send 2FA code to.'); + } + + // create verification code + return VerificationCode::generateSmsVerificationFor($user, '2fa', $messageCallback, [], $expiresAfter); + } + + if ($method === 'email') { + // if user has no phone number throw error + if (!$user->email) { + throw new \Exception('No email to send 2FA code to.'); + } + + // 2FA Message Lines + $linesCallback = function ($verificationCode) { + return [ + new HtmlString('

Your two-factor authentication code is: ' . $verificationCode->code . '

'), + ]; + }; + + // create verification code + return VerificationCode::generateEmailVerificationFor($user, '2fa', $messageCallback, $linesCallback, [], $expiresAfter); + } + + throw new \Exception('Invalid 2FA method selected in settings.'); + } + + /** + * Create a Two-Factor Authentication session if enabled. + * + * @param string $identity the user identity + * + * @return string|null the Two-Factor Authentication session key, or null if not enabled + */ + public static function createTwoFaSessionIfEnabled(string $identity): ?string + { + $user = static::getUserFromIdentity($identity); + if (!$user) { + return null; + } + + $isTwoFaEnabled = self::isEnabled($user); + + if ($isTwoFaEnabled) { + return self::start($identity); + } + + return null; + } + + /** + * Check if Two-Factor Authentication is enabled. + * + * @return bool true if Two-Factor Authentication is enabled, false otherwise + */ + public static function isEnabled(User $user): bool + { + $twoFaSettings = static::getTwoFaSettingsForUser($user); + if (!$twoFaSettings) { + return false; + } + + return $twoFaSettings->getBoolean('enabled'); + } + + public static function shouldEnforce(User $user): bool + { + $systemEnforced = static::isSystemEnforced(); + $companyEnforced = static::isCompanyEnforced($user->company); + $userEnabled = static::isEnabled($user); + + return $userEnabled ? !$userEnabled : $systemEnforced || $companyEnforced; + } + + /** + * Check if Two-Factor Authentication is enforced for company. + * + * @return bool true if Two-Factor Authentication is enforced, false otherwise + */ + public static function isCompanyEnforced(Company $company): bool + { + $twoFaSettings = static::getTwoFaSettingsForCompany($company); + + if ($twoFaSettings) { + return $twoFaSettings->getBoolean('enforced'); + } + return false; + } + + public static function isSystemEnforced(): bool + { + $twoFaSettings = static::getTwoFaConfiguration(); + + if (!$twoFaSettings) { + return false; + } + + return $twoFaSettings->getBoolean('enforced'); + } + + /** + * Start a Two-Factor Authentication session. + * + * @param string $identity the user identity + * @param int $tokenLength the length of the generated token + * + * @return string|null the Two-Factor Authentication session key, or null on failure + */ + public static function start(string $identity, int $tokenLength = 40): ?string + { + $user = static::getUserFromIdentity($identity); + + if ($user) { + $token = Str::random($tokenLength); + $twoFaSessionKey = static::createTwoFaSessionKey($user, $token, true); + + return static::encryptSessionKey($twoFaSessionKey, $user->uuid); + } + + return null; + } + + /** + * Verify a Two-Factor Authentication code and return a user token. + * + * @param string $code the user-provided verification code + * @param string $token the Two-Factor Authentication token + * @param string $clientToken the client session token + * + * @return string the user token + * + * @throws \Exception if verification code is invalid or expired, or session is invalid + */ + public static function verifyCode(string $code, string $token, string $clientToken): string + { + // Get verification code from the client token + $verificationCode = static::getVerificationCodeFromClientToken($clientToken); + + // If no verification code return null + if (!$verificationCode) { + throw new \Exception('Verification code is invalid.'); + } + + // If we have verification code then get user from it + if ($verificationCode) { + // Get user from verification code + $user = static::getUserFromVerificationCode($verificationCode); + + // If no user found in the verification code + if (!$user) { + throw new \Exception('User not found for verification code.'); + } + + // Get the user identity + $identity = $user->getIdentity(); + + // Next we will validate the session token + if (static::validateSessionToken($token, $identity, $clientToken)) { + // Get the two factor session key + $twoFaSessionKey = static::decryptSessionKey($token, $user->uuid); + + // If session key is valid + if (static::isTwoFaSessionKeyValid($twoFaSessionKey, $user)) { + // Make sure verification code has not expired + if ($verificationCode->hasExpired()) { + throw new \Exception('Verification code has expired.'); + } + + // Check if verification code matches user provided code + $verificationCodeMatches = $verificationCode->code === $code; + if ($verificationCodeMatches) { + // Kill the two fa session + Redis::del($twoFaSessionKey); + + // Authenticate the user + $token = $user->createToken($user->uuid); + + return $token->plainTextToken; + } + + throw new \Exception('Verification code does not match.'); + } + } + } + + throw new \Exception('Verification code is invalid.'); + } + + /** + * Resend a Two-Factor Authentication verification code. + * + * @param string $identity the user identity + * @param string $token the Two-Factor Authentication token + * + * @return string the newly generated client session token + * + * @throws \Exception if no user found or the Two-Factor Authentication session is invalid + */ + public static function resendCode(string $identity, string $token): string + { + $user = static::getUserFromIdentity($identity); + if (!$user) { + throw new \Exception('No user found using the provided identity'); + } + + // Make sure two factor session is valid + if (!static::validateSessionToken($token, $identity)) { + throw new \Exception('2FA session is invalid.'); + } + + // Send new verification code for user + $verificationCode = static::sendVerificationCode($user); + + // Return with newly generated client session token for the new verification code + return static::createClientSessionToken($verificationCode); + } + + /** + * Create a client session token for a verification code. + * + * @param \Fleetbase\Models\VerificationCode $verificationCode the verification code + * @param int $expiresAfter the expiration time for the client session token in seconds + * + * @return string the client session token + */ + public static function createClientSessionToken(VerificationCode $verificationCode, int $expiresAfter = 61): string + { + $expiresAfter = Carbon::now()->addSeconds($expiresAfter); + $clientToken = base64_encode($expiresAfter . '|' . $verificationCode->uuid . '|' . Str::random()); + + return $clientToken; + } + + /** + * Check if a Two-Factor Authentication session key is valid. + * + * @param string $twoFaSessionKey the Two-Factor Authentication session key + * @param \Fleetbase\Models\User $user the user the session key was created for + * + * @return bool true if the session key is valid, false otherwise + */ + public static function isTwoFaSessionKeyValid(string $twoFaSessionKey, User $user): bool + { + $exists = Redis::exists($twoFaSessionKey); + + if ($exists) { + $parts = explode(':', $twoFaSessionKey); + $userId = Arr::get($parts, 1); + + return Str::isUuid($userId) && $userId === $user->uuid; + } + + return false; + } + + /** + * Forget the Two-Factor Authentication session based on the provided token and identity. + * + * @param string $token the token associated with the Two-Factor Authentication session + * @param string $identity The identity (e.g., username) of the user. + * + * @return bool returns true if the Two-Factor Authentication session was successfully forgotten, + * false otherwise + * + * @throws \Exception thrown when no user is found for the provided identity + */ + public static function forgetTwoFaSession(string $token, string $identity): bool + { + $user = static::getUserFromIdentity($identity); + if (!$user) { + throw new \Exception('No user found for the identity provided.'); + } + + // Get session key and destroy it + $twoFaSessionKey = static::decryptSessionKey($token, $user); + + return Redis::del($twoFaSessionKey); + } + + /** + * Create a Two-Factor Authentication session key. + * + * @param \Fleetbase\Models\User $user the user for whom the session key is created + * @param string $token the Two-Factor Authentication token + * @param bool $storeInCache whether to store the key in the cache + * @param int $expiresAfter the expiration time for the session key in seconds + * + * @return string the Two-Factor Authentication session key + */ + private static function createTwoFaSessionKey(User $user, string $token, bool $storeInCache = false, int $expiresAfter = 600): string + { + $twoFaSessionKey = 'two_fa_session:' . $user->uuid . ':' . $token; + + if ($storeInCache) { + $expirationTime = Carbon::now()->addSeconds($expiresAfter)->timestamp; + Redis::set($twoFaSessionKey, $user->uuid, 'EX', $expirationTime); + } + + return $twoFaSessionKey; + } + + /** + * Get a user based on the provided identity (email or phone). + * + * @param string $identity the user identity (email or phone) + * + * @return \Fleetbase\Models\User|null the user, or null if not found + */ + private static function getUserFromIdentity(string $identity): ?User + { + return User::where(function ($query) use ($identity) { + $query->where('email', $identity)->orWhere('phone', $identity); + })->first(); + } + + /** + * Get a user based on the provided verification code. + * + * @param \Fleetbase\Models\VerificationCode|null $verificationCode the verification code + * + * @return \Fleetbase\Models\User|null the user, or null if not found + */ + private static function getUserFromVerificationCode(VerificationCode $verificationCode = null): ?User + { + if ($verificationCode instanceof VerificationCode) { + $subject = $verificationCode->subject; + + if ($subject instanceof User) { + return $subject; + } + } + + return null; + } + + /** + * Decode a client session token. + * + * @param string $clientToken the client session token + * + * @return array the decoded client session token parts + */ + private static function decodeClientToken(string $clientToken): array + { + $clientTokenDecoded = base64_decode($clientToken); + $clientTokenParts = explode('|', $clientTokenDecoded); + + return $clientTokenParts; + } + + /** + * Get a verification code based on the provided client session token. + * + * @param string $clientToken the client session token + * + * @return \Fleetbase\Models\VerificationCode|null the verification code, or null if not found + */ + private static function getVerificationCodeFromClientToken(string $clientToken): ?VerificationCode + { + $clientTokenParts = static::decodeClientToken($clientToken); + $verificationCodeId = $clientTokenParts[1]; + + if ($verificationCodeId) { + $verificationCode = VerificationCode::where('uuid', $verificationCodeId)->first(); + + if ($verificationCode) { + return $verificationCode; + } + } + + return null; + } + + /** + * Encrypts the session key using AES-256-CBC encryption with an initialization vector (IV). + * + * @param mixed $data the data to be encrypted + * @param string $key the encryption key + * + * @return string the base64-encoded result of encrypting the data + */ + private static function encryptSessionKey($data, string $key): ?string + { + // Encrypt the data + $ivLength = openssl_cipher_iv_length('aes-256-cbc'); + if ($ivLength === false) { + return null; + } + $iv = openssl_random_pseudo_bytes($ivLength); + if ($iv === false) { + return null; + } + $encrypted = openssl_encrypt(gzcompress($data), 'aes-256-cbc', $key, 0, $iv); + if ($encrypted === false) { + return null; + } + + // Combine IV and encrypted data + $result = $iv . $encrypted; + + return base64_encode($result); + } + + /** + * Decrypts the encrypted session key using AES-256-CBC decryption with an initialization vector (IV). + * + * @param string $encrypted the base64-encoded encrypted data + * @param string $key the decryption key + * + * @return mixed the decrypted and decompressed original data + */ + private static function decryptSessionKey(string $encrypted, string $key): ?string + { + // Decode from base64 + $data = base64_decode($encrypted); + + // Extract IV and encrypted data + $ivLength = openssl_cipher_iv_length('aes-256-cbc'); + $iv = substr($data, 0, $ivLength); + $encryptedData = substr($data, $ivLength); + + // Decrypt and decompress + $decrypted = openssl_decrypt($encryptedData, 'aes-256-cbc', $key, 0, $iv); + if ($decrypted === false) { + return null; + } + + $decompressed = gzuncompress($decrypted); + if ($decompressed === false) { + return null; + } + + return $decompressed; + } +} diff --git a/src/Support/Utils.php b/src/Support/Utils.php index 98d0ab7..b7b8e44 100644 --- a/src/Support/Utils.php +++ b/src/Support/Utils.php @@ -2199,4 +2199,34 @@ public static function addWwwToUrl($url) return $url; } + + public static function getModelCountry(\Illuminate\Database\Eloquent\Model $model): ?string + { + if (isset($model->country) && is_string($model->country)) { + if (strlen($model->country) === 2) { + return $model->country; + } + + $countryCode = static::getCountryCodeByName($model->country); + + if (strlen($countryCode) === 2) { + return $countryCode; + } + } + + if ($model instanceof Company) { + return null; + } + + // attempt to get country code from current company session + if (session()->has('company')) { + $company = Company::where('uuid', session('company'))->first(); + + if ($company) { + return static::getModelCountry($company); + } + } + + return null; + } } diff --git a/src/routes.php b/src/routes.php index 04e15a5..f138df3 100644 --- a/src/routes.php +++ b/src/routes.php @@ -87,12 +87,28 @@ function ($router) { $router->post('accept-company-invite', 'UserController@acceptCompanyInvite'); } ); + $router->group( + ['prefix' => 'companies'], + function ($router) { + $router->get('find/{id}', 'CompanyController@findCompany'); + } + ); $router->group( ['prefix' => 'settings'], function ($router) { $router->get('branding', 'SettingController@getBrandingSettings'); } ); + $router->group( + ['prefix' => 'two-fa'], + function ($router) { + $router->get('check', 'TwoFaController@checkTwoFactor'); + $router->post('validate', 'TwoFaController@validateSession'); + $router->post('verify', 'TwoFaController@verifyCode'); + $router->post('resend', 'TwoFaController@resendCode'); + $router->post('invalidate', 'TwoFaController@invalidateSession'); + } + ); $router->group( ['middleware' => ['fleetbase.protected']], function ($router) { @@ -136,6 +152,14 @@ function ($router, $controller) { $router->post('test-notification-channels-config', $controller('testNotificationChannelsConfig')); } ); + $router->fleetbaseRoutes( + 'two-fa', + function ($router, $controller) { + $router->post('config', $controller('saveSystemConfig')); + $router->get('config', $controller('getSystemConfig')); + $router->get('enforce', $controller('shouldEnforce')); + } + ); $router->fleetbaseRoutes('api-events'); $router->fleetbaseRoutes('api-request-logs'); $router->fleetbaseRoutes( @@ -146,7 +170,10 @@ function ($router, $controller) { } ); $router->fleetbaseRoutes('webhook-request-logs'); - $router->fleetbaseRoutes('companies'); + $router->fleetbaseRoutes('companies', function ($router, $controller) { + $router->get('two-fa', $controller('getTwoFactorSettings')); + $router->post('two-fa', $controller('saveTwoFactorSettings')); + }); $router->fleetbaseRoutes( 'users', function ($router, $controller) { @@ -158,6 +185,10 @@ function ($router, $controller) { $router->delete('bulk-delete', $controller('bulkDelete')); $router->post('resend-invite', $controller('resendInvitation')); $router->post('set-password', $controller('setCurrentUserPassword')); + $router->post('validate-password', $controller('validatePassword')); + $router->post('change-password', $controller('changeUserPassword')); + $router->post('two-fa', $controller('saveTwoFactorSettings')); + $router->get('two-fa', $controller('getTwoFactorSettings')); } ); $router->fleetbaseRoutes('user-devices');