Page MenuHomePhorge

No OneTemporary

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

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)

Event Timeline