Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F256656
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
21 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php
index 8fd3ee4c..3a247992 100644
--- a/src/app/Http/Controllers/API/V4/NGINXController.php
+++ b/src/app/Http/Controllers/API/V4/NGINXController.php
@@ -1,326 +1,331 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class NGINXController extends Controller
{
/**
* Authorize with the provided credentials.
*
* @param string $login The login name
* @param string $password The password
* @param string $clientIP The client ip
*
* @return \App\User The user
*
* @throws \Exception If the authorization fails.
*/
private function authorizeRequest($login, $password, $clientIP)
{
if (empty($login)) {
throw new \Exception("Empty login");
}
if (empty($password)) {
throw new \Exception("Empty password");
}
if (empty($clientIP)) {
throw new \Exception("No client ip");
}
$user = \App\User::where('email', $login)->first();
if (!$user) {
throw new \Exception("User not found");
}
// TODO: validate the user's domain is A-OK (active, confirmed, not suspended, ldapready)
// TODO: validate the user is A-OK (active, not suspended, ldapready, imapready)
// TODO: we could use User::findAndAuthenticate() with some modifications here
if (!Hash::check($password, $user->password)) {
$attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
// Avoid setting a password failure reason if we previously accepted the location.
if (!$attempt->isAccepted()) {
$attempt->reason = \App\AuthAttempt::REASON_PASSWORD;
$attempt->save();
$attempt->notify();
}
throw new \Exception("Password mismatch");
}
// validate country of origin against restrictions, otherwise bye bye
if (!$user->validateLocation($clientIP)) {
\Log::info("Failed authentication attempt due to country code mismatch for user: {$login}");
$attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
$attempt->deny(\App\AuthAttempt::REASON_GEOLOCATION);
$attempt->notify();
throw new \Exception("Country code mismatch");
}
// TODO: Apply some sort of limit for Auth-Login-Attempt -- docs say it is the number of
// attempts over the same authAttempt.
// Check 2fa
if (\App\CompanionApp::where('user_id', $user->id)->exists()) {
$authAttempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
if (!$authAttempt->waitFor2FA()) {
throw new \Exception("2fa failed");
}
}
return $user;
}
/**
* Convert domain.tld\username into username@domain for activesync
*
* @param string $username The original username.
*
* @return string The username in canonical form
*/
private function normalizeUsername($username)
{
$usernameParts = explode("\\", $username);
if (count($usernameParts) == 2) {
$username = $usernameParts[1];
if (!strpos($username, '@') && !empty($usernameParts[0])) {
$username .= '@' . $usernameParts[0];
}
}
return $username;
}
/**
* Authentication request from the ngx_http_auth_request_module
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function httpauth(Request $request)
{
/**
Php-Auth-Pw: simple123
Php-Auth-User: john@kolab.org
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Gpc: 1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0
X-Forwarded-For: 31.10.153.58
X-Forwarded-Proto: https
X-Original-Uri: /iRony/
X-Real-Ip: 31.10.153.58
*/
$username = $this->normalizeUsername($request->headers->get('Php-Auth-User', ""));
$password = $request->headers->get('Php-Auth-Pw', null);
+ if (empty($username)) {
+ //Allow unauthenticated requests
+ return response("");
+ }
+
if (empty($password)) {
\Log::debug("Authentication attempt failed: Empty password provided.");
return response("", 401);
}
try {
$this->authorizeRequest(
$username,
$password,
$request->headers->get('X-Real-Ip', null),
);
} catch (\Exception $e) {
\Log::debug("Authentication attempt failed: {$e->getMessage()}");
return response("", 403);
}
\Log::debug("Authentication attempt succeeded");
return response("");
}
/**
* Authentication request.
*
* @todo: Separate IMAP(+STARTTLS) from IMAPS, same for SMTP/submission. =>
* I suppose that's not necessary given that we have the information avialable in the headers?
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function authenticate(Request $request)
{
/**
* Auth-Login-Attempt: 1
* Auth-Method: plain
* Auth-Pass: simple123
* Auth-Protocol: imap
* Auth-Ssl: on
* Auth-User: john@kolab.org
* Client-Ip: 127.0.0.1
* Host: 127.0.0.1
*
* Auth-SSL: on
* Auth-SSL-Verify: SUCCESS
* Auth-SSL-Subject: /CN=example.com
* Auth-SSL-Issuer: /CN=example.com
* Auth-SSL-Serial: C07AD56B846B5BFF
* Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad
*/
$password = $request->headers->get('Auth-Pass', null);
$username = $request->headers->get('Auth-User', null);
$ip = $request->headers->get('Client-Ip', null);
try {
$user = $this->authorizeRequest(
$username,
$password,
$ip,
);
} catch (\Exception $e) {
return $this->byebye($request, $e->getMessage());
}
// All checks passed
switch ($request->headers->get('Auth-Protocol')) {
case "imap":
return $this->authenticateIMAP($request, (bool) $user->getSetting('guam_enabled'), $password);
case "smtp":
return $this->authenticateSMTP($request, $password);
default:
return $this->byebye($request, "unknown protocol in request");
}
}
/**
* Authentication request for roundcube imap.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function authenticateRoundcube(Request $request)
{
/**
* Auth-Login-Attempt: 1
* Auth-Method: plain
* Auth-Pass: simple123
* Auth-Protocol: imap
* Auth-Ssl: on
* Auth-User: john@kolab.org
* Client-Ip: 127.0.0.1
* Host: 127.0.0.1
*
* Auth-SSL: on
* Auth-SSL-Verify: SUCCESS
* Auth-SSL-Subject: /CN=example.com
* Auth-SSL-Issuer: /CN=example.com
* Auth-SSL-Serial: C07AD56B846B5BFF
* Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad
*/
$password = $request->headers->get('Auth-Pass', null);
$username = $request->headers->get('Auth-User', null);
$ip = $request->headers->get('Proxy-Protocol-Addr', null);
try {
$user = $this->authorizeRequest(
$username,
$password,
$ip,
);
} catch (\Exception $e) {
return $this->byebye($request, $e->getMessage());
}
// All checks passed
switch ($request->headers->get('Auth-Protocol')) {
case "imap":
return $this->authenticateIMAP($request, false, $password);
default:
return $this->byebye($request, "unknown protocol in request");
}
}
/**
* Create an imap authentication response.
*
* @param \Illuminate\Http\Request $request The API request.
* @param bool $prefGuam Wether or not guam is enabled.
* @param string $password The password to include in the response.
*
* @return \Illuminate\Http\Response The response
*/
private function authenticateIMAP(Request $request, $prefGuam, $password)
{
if ($prefGuam) {
$port = \config('imap.guam_port');
} else {
$port = \config('imap.imap_port');
}
$response = response("")->withHeaders(
[
"Auth-Status" => "OK",
"Auth-Server" => \config('imap.host'),
"Auth-Port" => $port,
"Auth-Pass" => $password
]
);
return $response;
}
/**
* Create an smtp authentication response.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $password The password to include in the response.
*
* @return \Illuminate\Http\Response The response
*/
private function authenticateSMTP(Request $request, $password)
{
$response = response("")->withHeaders(
[
"Auth-Status" => "OK",
"Auth-Server" => \config('smtp.host'),
"Auth-Port" => \config('smtp.port'),
"Auth-Pass" => $password
]
);
return $response;
}
/**
* Create a failed-authentication response.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $reason The reason for the failure.
*
* @return \Illuminate\Http\Response The response
*/
private function byebye(Request $request, $reason = null)
{
\Log::debug("Byebye: {$reason}");
$response = response("")->withHeaders(
[
"Auth-Status" => "authentication failure",
"Auth-Wait" => 3
]
);
return $response;
}
}
diff --git a/src/tests/Feature/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php
index 11f58662..9578a6f6 100644
--- a/src/tests/Feature/Controller/NGINXTest.php
+++ b/src/tests/Feature/Controller/NGINXTest.php
@@ -1,292 +1,292 @@
<?php
namespace Tests\Feature\Controller;
use Tests\TestCase;
class NGINXTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$john = $this->getTestUser('john@kolab.org');
\App\CompanionApp::where('user_id', $john->id)->delete();
\App\AuthAttempt::where('user_id', $john->id)->delete();
$john->setSettings([
'limit_geo' => null,
'guam_enabled' => null,
]);
\App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete();
$this->useServicesUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
\App\CompanionApp::where('user_id', $john->id)->delete();
\App\AuthAttempt::where('user_id', $john->id)->delete();
$john->setSettings([
'limit_geo' => null,
'guam_enabled' => null,
]);
\App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete();
parent::tearDown();
}
/**
* Test the webhook
*/
public function testNGINXWebhook(): void
{
$john = $this->getTestUser('john@kolab.org');
$response = $this->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'authentication failure');
$pass = \App\Utils::generatePassphrase();
$headers = [
'Auth-Login-Attempt' => '1',
'Auth-Method' => 'plain',
'Auth-Pass' => $pass,
'Auth-Protocol' => 'imap',
'Auth-Ssl' => 'on',
'Auth-User' => 'john@kolab.org',
'Client-Ip' => '127.0.0.1',
'Host' => '127.0.0.1',
'Auth-SSL' => 'on',
'Auth-SSL-Verify' => 'SUCCESS',
'Auth-SSL-Subject' => '/CN=example.com',
'Auth-SSL-Issuer' => '/CN=example.com',
'Auth-SSL-Serial' => 'C07AD56B846B5BFF',
'Auth-SSL-Fingerprint' => '29d6a80a123d13355ed16b4b04605e29cb55a5ad'
];
// Pass
$response = $this->withHeaders($headers)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'OK');
$response->assertHeader('auth-port', '12143');
// Invalid Password
$modifiedHeaders = $headers;
$modifiedHeaders['Auth-Pass'] = "Invalid";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'authentication failure');
// Empty Password
$modifiedHeaders = $headers;
$modifiedHeaders['Auth-Pass'] = "";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'authentication failure');
// Empty User
$modifiedHeaders = $headers;
$modifiedHeaders['Auth-User'] = "";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'authentication failure');
// Invalid User
$modifiedHeaders = $headers;
$modifiedHeaders['Auth-User'] = "foo@kolab.org";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'authentication failure');
// Empty Ip
$modifiedHeaders = $headers;
$modifiedHeaders['Client-Ip'] = "";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'authentication failure');
// SMTP Auth Protocol
$modifiedHeaders = $headers;
$modifiedHeaders['Auth-Protocol'] = "smtp";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'OK');
$response->assertHeader('auth-server', '127.0.0.1');
$response->assertHeader('auth-port', '10465');
$response->assertHeader('auth-pass', $pass);
// Empty Auth Protocol
$modifiedHeaders = $headers;
$modifiedHeaders['Auth-Protocol'] = "";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'authentication failure');
// Guam
$john->setSettings(['guam_enabled' => 'true']);
$response = $this->withHeaders($headers)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'OK');
$response->assertHeader('auth-server', '127.0.0.1');
$response->assertHeader('auth-port', '9143');
$companionApp = $this->getTestCompanionApp(
'testdevice',
$john,
[
'notification_token' => 'notificationtoken',
'mfa_enabled' => 1,
'name' => 'testname',
]
);
// 2-FA with accepted auth attempt
$authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1");
$authAttempt->accept();
$response = $this->withHeaders($headers)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'OK');
// Deny
$authAttempt->deny();
$response = $this->withHeaders($headers)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'authentication failure');
// 2-FA without device
$companionApp->delete();
$response = $this->withHeaders($headers)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'OK');
// Geo-lockin (failure)
$john->setSettings(['limit_geo' => '["PL","US"]']);
$headers['Auth-Protocol'] = 'imap';
$headers['Client-Ip'] = '127.0.0.1';
$response = $this->withHeaders($headers)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'authentication failure');
$authAttempt = \App\AuthAttempt::where('ip', $headers['Client-Ip'])->where('user_id', $john->id)->first();
$this->assertSame('geolocation', $authAttempt->reason);
\App\AuthAttempt::where('user_id', $john->id)->delete();
// Geo-lockin (success)
\App\IP4Net::create([
'net_number' => '127.0.0.0',
'net_broadcast' => '127.255.255.255',
'net_mask' => 8,
'country' => 'US',
'rir_name' => 'test',
'serial' => 1,
]);
$response = $this->withHeaders($headers)->get("api/webhooks/nginx");
$response->assertStatus(200);
$response->assertHeader('auth-status', 'OK');
$this->assertCount(0, \App\AuthAttempt::where('user_id', $john->id)->get());
}
/**
* Test the httpauth webhook
*/
public function testNGINXHttpAuthHook(): void
{
$john = $this->getTestUser('john@kolab.org');
$response = $this->get("api/webhooks/nginx-httpauth");
- $response->assertStatus(401);
+ $response->assertStatus(200);
$pass = \App\Utils::generatePassphrase();
$headers = [
'Php-Auth-Pw' => $pass,
'Php-Auth-User' => 'john@kolab.org',
'X-Forwarded-For' => '127.0.0.1',
'X-Forwarded-Proto' => 'https',
'X-Original-Uri' => '/iRony/',
'X-Real-Ip' => '127.0.0.1',
];
// Pass
$response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth");
$response->assertStatus(200);
// domain.tld\username
$modifiedHeaders = $headers;
$modifiedHeaders['Php-Auth-User'] = "kolab.org\\john";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth");
$response->assertStatus(200);
// Invalid Password
$modifiedHeaders = $headers;
$modifiedHeaders['Php-Auth-Pw'] = "Invalid";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth");
$response->assertStatus(403);
// Empty Password
$modifiedHeaders = $headers;
$modifiedHeaders['Php-Auth-Pw'] = "";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth");
$response->assertStatus(401);
// Empty User
$modifiedHeaders = $headers;
$modifiedHeaders['Php-Auth-User'] = "";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth");
- $response->assertStatus(403);
+ $response->assertStatus(200);
// Invalid User
$modifiedHeaders = $headers;
$modifiedHeaders['Php-Auth-User'] = "foo@kolab.org";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth");
$response->assertStatus(403);
// Empty Ip
$modifiedHeaders = $headers;
$modifiedHeaders['X-Real-Ip'] = "";
$response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth");
$response->assertStatus(403);
$companionApp = $this->getTestCompanionApp(
'testdevice',
$john,
[
'notification_token' => 'notificationtoken',
'mfa_enabled' => 1,
'name' => 'testname',
]
);
// 2-FA with accepted auth attempt
$authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1");
$authAttempt->accept();
$response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth");
$response->assertStatus(200);
// Deny
$authAttempt->deny();
$response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth");
$response->assertStatus(403);
// 2-FA without device
$companionApp->delete();
$response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth");
$response->assertStatus(200);
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Mon, Jun 9, 1:17 AM (1 d, 6 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
196729
Default Alt Text
(21 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment