Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@
"lcobucci/clock": "3.3.1",
"lcobucci/jwt": "^5.4",
"maatwebsite/excel": "^3.1",
"milon/barcode": "^10.0",
"mossadal/math-parser": "^1.3",
"phpoffice/phpspreadsheet": "^1.28",
"phrity/websocket": "^1.7",
"pragmarx/google2fa": "^8.0",
"rlanvin/php-rrule": "^2.4",
"sentry/sentry-laravel": "*",
"spatie/laravel-activitylog": "^4.7",
Expand Down
17 changes: 10 additions & 7 deletions src/Http/Controllers/Internal/v1/TwoFaController.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,20 @@ public function getSystemConfig()
}

/**
* Check Two-Factor Authentication status for a given user identity.
* Retained for older consoles, which call this before submitting the password.
*
* It used to start a 2FA session from the identity alone, which let the emailed/SMS
* code stand in for the password and revealed which accounts have 2FA enabled. A 2FA
* session is now only started by `auth/login` once the password checks out, so this
* always reports 2FA as off and older consoles continue to the password login.
*
* @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,
'twoFaSession' => null,
'isTwoFaEnabled' => false,
]);
}

Expand All @@ -76,6 +77,7 @@ public function validateSession(TwoFaValidationRequest $request)

return response()->json([
'clientToken' => $validClientToken,
'method' => TwoFactorAuth::getChallengeMethod($identity, $validClientToken),
'expired' => false,
]);
} catch (\Exception $e) {
Expand Down Expand Up @@ -137,6 +139,7 @@ public function resendCode(Request $request)

return response()->json([
'clientToken' => $clientToken,
'method' => TwoFactorAuth::getChallengeMethod($identity, $clientToken),
]);
} catch (\Exception $e) {
return response()->error($e->getMessage());
Expand Down
102 changes: 102 additions & 0 deletions src/Http/Controllers/Internal/v1/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -711,11 +711,113 @@ public function saveTwoFactorSettings(Request $request)
return response()->error('No user session found', 401);
}

if (($twoFaSettings['method'] ?? null) === TwoFactorAuth::METHOD_AUTHENTICATOR_APP && !TwoFactorAuth::hasAuthenticatorApp($user)) {
return response()->error('Set up your authenticator app before choosing it as your two-factor method.', 422);
}

$twoFaSettings = TwoFactorAuth::saveTwoFaSettingsForUser($user, $twoFaSettings);

return response()->json($twoFaSettings->value);
}

/**
* Get the current user's authenticator app status.
*
* @return \Illuminate\Http\Response
*/
#[SkipAuthorizationCheck]
public function getAuthenticatorApp(Request $request)
{
return response()->json(TwoFactorAuth::getAuthenticatorStatus($request->user()));
}

/**
* Start setting up an authenticator app for the current user. Requires the current
* password, since it changes how the user signs in.
*
* @return \Illuminate\Http\Response
*/
#[SkipAuthorizationCheck]
public function setupAuthenticatorApp(Request $request)
{
$user = $request->user();
if (!$user->checkPassword((string) $request->input('password'))) {
return response()->error('The current password provided is invalid.', 422);
}

return response()->json(TwoFactorAuth::beginAuthenticatorEnrollment($user));
}

/**
* Confirm the current user's new authenticator app with a code from it. Returns the
* recovery codes, which are only shown this once.
*
* @return \Illuminate\Http\Response
*/
#[SkipAuthorizationCheck]
public function confirmAuthenticatorApp(Request $request)
{
$user = $request->user();

try {
$recoveryCodes = TwoFactorAuth::confirmAuthenticatorEnrollment($user, (string) $request->input('code'));
} catch (\Exception $e) {
return response()->error($e->getMessage(), 422);
}

return response()->json([
'recovery_codes' => $recoveryCodes,
'status' => TwoFactorAuth::getAuthenticatorStatus($user),
'settings' => TwoFactorAuth::getTwoFaSettingsForUser($user)->value,
]);
}

/**
* Remove the current user's authenticator app. Requires the current password.
*
* @return \Illuminate\Http\Response
*/
#[SkipAuthorizationCheck]
public function disableAuthenticatorApp(Request $request)
{
$user = $request->user();
if (!$user->checkPassword((string) $request->input('password'))) {
return response()->error('The current password provided is invalid.', 422);
}

TwoFactorAuth::disableAuthenticatorApp($user);

return response()->json([
'status' => TwoFactorAuth::getAuthenticatorStatus($user),
'settings' => TwoFactorAuth::getTwoFaSettingsForUser($user)->value,
]);
}

/**
* Replace the current user's recovery codes. Requires the current password.
*
* @return \Illuminate\Http\Response
*/
#[SkipAuthorizationCheck]
public function regenerateRecoveryCodes(Request $request)
{
$user = $request->user();
if (!$user->checkPassword((string) $request->input('password'))) {
return response()->error('The current password provided is invalid.', 422);
}

try {
$recoveryCodes = TwoFactorAuth::regenerateRecoveryCodes($user);
} catch (\Exception $e) {
return response()->error($e->getMessage(), 422);
}

return response()->json([
'recovery_codes' => $recoveryCodes,
'status' => TwoFactorAuth::getAuthenticatorStatus($user),
]);
}

/**
* Invite a user (new or existing) to join the current organisation.
*
Expand Down
2 changes: 1 addition & 1 deletion src/Models/VerificationCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public static function boot()
{
parent::boot();
static::creating(function ($model) {
$model->code = mt_rand(100000, 999999);
$model->code = random_int(100000, 999999);
});
}

Expand Down
107 changes: 107 additions & 0 deletions src/Support/Barcode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

namespace Fleetbase\Support;

use Milon\Barcode\DNS2D;
use Milon\Barcode\QRcode;

/**
* Generates QR codes and other 2D barcodes.
*
* Extensions should use this rather than calling `milon/barcode` directly, so the
* barcode library is owned and versioned in one place.
*/
class Barcode
{
/**
* Render a QR code as an SVG document.
*
* The code is drawn dark on a white background with a quiet zone around it, so it
* scans on any page background, including dark themes.
*
* @param string $data the content to encode
* @param string $errorCorrection L, M, Q or H
* @param int $quietZone the blank border, in modules (the QR spec asks for 4)
*
* @throws \InvalidArgumentException if the data cannot be encoded
*/
public static function qrCodeSvg(string $data, string $errorCorrection = 'M', int $quietZone = 4): string
{
$matrix = static::qrCodeMatrix($data, $errorCorrection);
$rows = $matrix['num_rows'];
$cols = $matrix['num_cols'];
$width = $cols + ($quietZone * 2);
$height = $rows + ($quietZone * 2);

// One path segment per horizontal run of dark modules keeps the SVG small.
$path = '';
for ($row = 0; $row < $rows; $row++) {
$col = 0;
while ($col < $cols) {
if (empty($matrix['bcode'][$row][$col])) {
$col++;
continue;
}

$start = $col;
while ($col < $cols && !empty($matrix['bcode'][$row][$col])) {
$col++;
}

$run = $col - $start;
$path .= 'M' . ($start + $quietZone) . ' ' . ($row + $quietZone) . 'h' . $run . 'v1h-' . $run . 'z';
}
}

return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . $width . ' ' . $height . '" shape-rendering="crispEdges">'
. '<rect width="100%" height="100%" fill="#ffffff"/>'
. '<path fill="#000000" d="' . $path . '"/>'
. '</svg>';
}

/**
* Render a QR code as an SVG data URI, ready for an `<img src>`.
*
* @param string $data the content to encode
* @param string $errorCorrection L, M, Q or H
*/
public static function qrCodeDataUri(string $data, string $errorCorrection = 'M'): string
{
return 'data:image/svg+xml;base64,' . base64_encode(static::qrCodeSvg($data, $errorCorrection));
}

/**
* Render a 2D barcode as a base64-encoded PNG, with a transparent background.
*
* @param string $data the content to encode
* @param string $type a `milon/barcode` 2D type, e.g. `QRCODE`, `QRCODE,H`, `PDF417` or `DATAMATRIX`
* @param int $w the width of one module, in pixels
* @param int $h the height of one module, in pixels
*
* @return string|false the PNG, or false when no image library is available
*/
public static function png(string $data, string $type = 'QRCODE', int $w = 3, int $h = 3): string|false
{
return (new DNS2D())->setStorPath(sys_get_temp_dir())->getBarcodePNG($data, $type, $w, $h);
}

/**
* Get the module matrix for a QR code.
*
* @return array<string, mixed> with `num_rows`, `num_cols` and the `bcode` module rows
*/
protected static function qrCodeMatrix(string $data, string $errorCorrection): array
{
$errorCorrection = strtoupper($errorCorrection);
if (!in_array($errorCorrection, ['L', 'M', 'Q', 'H'], true)) {
throw new \InvalidArgumentException('QR code error correction must be L, M, Q or H.');
}

$matrix = $data === '' ? false : (new QRcode($data, $errorCorrection))->getBarcodeArray();
if (!is_array($matrix) || empty($matrix['num_rows'])) {
throw new \InvalidArgumentException('The data could not be encoded as a QR code.');
}

return $matrix;
}
}
Loading