diff --git a/docker/proxy/rootfs/etc/nginx/nginx.conf b/docker/proxy/rootfs/etc/nginx/nginx.conf index 311c500a..0f806916 100644 --- a/docker/proxy/rootfs/etc/nginx/nginx.conf +++ b/docker/proxy/rootfs/etc/nginx/nginx.conf @@ -1,85 +1,153 @@ # For more information on configuration, see: # * Official English Documentation: http://nginx.org/en/docs/ # * Official Russian Documentation: http://nginx.org/ru/docs/ user nginx; worker_processes auto; error_log /var/log/nginx/error.log; pid /run/nginx.pid; # Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. include /usr/share/nginx/modules/*.conf; events { worker_connections 1024; } http { log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; types_hash_max_size 2048; include /etc/nginx/mime.types; default_type application/octet-stream; map $http_upgrade $connection_upgrade { default upgrade; '' close; } # Load modular configuration files from the /etc/nginx/conf.d directory. # See http://nginx.org/en/docs/ngx_core_module.html#include # for more information. include /etc/nginx/conf.d/*.conf; server { listen [::]:443 ssl ipv6only=on; listen 443 ssl; ssl_certificate /etc/letsencrypt/live/$server_name/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/$server_name/privkey.pem; server_name kanarip.internet-box.ch; root /usr/share/nginx/html; # Load configuration files for the default server block. include /etc/nginx/default.d/*.conf; location / { proxy_pass http://127.0.0.1:8000; proxy_redirect off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_no_cache 1; proxy_cache_bypass 1; } location /openvidu { proxy_pass https://127.0.0.1:8443; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; } + location /roundcubemail { + proxy_pass http://127.0.0.1:9080; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + + location /kolab-webadmin { + proxy_pass http://127.0.0.1:9080; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + + location /Microsoft-Server-ActiveSync { + auth_request /auth; + #auth_request_set $auth_status $upstream_status; + + proxy_pass http://127.0.0.1:9080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_send_timeout 910s; + proxy_read_timeout 910s; + fastcgi_send_timeout 910s; + fastcgi_read_timeout 910s; + } + + location ~* ^/\\.well-known/(caldav|carddav) { + proxy_pass http://127.0.0.1:9080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /iRony { + auth_request /auth; + #auth_request_set $auth_status $upstream_status; + + proxy_pass http://127.0.0.1:9080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location = /auth { + internal; + proxy_pass http://127.0.0.1:8000/api/webhooks/nginx-httpauth; + proxy_pass_request_body off; + proxy_set_header Host services.APP_WEBSITE_DOMAIN; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + error_page 404 /404.html; location = /40x.html { } error_page 500 502 503 504 /50x.html; location = /50x.html { } } } diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php index 82fa08de..622e8fb2 100644 --- a/src/app/Http/Controllers/API/V4/NGINXController.php +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -1,203 +1,290 @@ - * I suppose that's not necessary given that we have the information avialable in the headers? + * @param string $login The login name + * @param string $password The password + * @param string $clientIP The client ip * - * @param \Illuminate\Http\Request $request The API request. + * @return \App\User The user * - * @return \Illuminate\Http\Response The response + * @throws \Exception If the authorization fails. */ - public function authenticate(Request $request) + private function authorizeRequest($login, $password, $clientIP) { - /** - * 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 - */ - - \Log::debug("Authentication attempt"); - \Log::debug($request->headers); - - $login = $request->headers->get('Auth-User', null); - if (empty($login)) { - return $this->byebye($request, "Empty login"); + throw new \Exception("Empty login"); } - // validate password, otherwise bye bye - $password = $request->headers->get('Auth-Pass', null); - if (empty($password)) { - return $this->byebye($request, "Empty password"); + throw new \Exception("Empty password"); } - $clientIP = $request->headers->get('Client-Ip', null); - if (empty($clientIP)) { - return $this->byebye($request, "No client ip"); + throw new \Exception("No client ip"); } - // validate user exists, otherwise bye bye $user = \App\User::where('email', $login)->first(); - if (!$user) { - return $this->byebye($request, "User not found"); + 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) 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(); } - \Log::info("Failed authentication attempt due to password mismatch for user: {$login}"); - return $this->byebye($request, "Password mismatch"); + throw new \Exception("Password mismatch"); } // validate country of origin against restrictions, otherwise bye bye $countryCodes = json_decode($user->getSetting('limit_geo', "[]")); \Log::debug("Countries for {$user->email}: " . var_export($countryCodes, true)); if (!empty($countryCodes)) { $country = \App\Utils::countryForIP($clientIP); if (!in_array($country, $countryCodes)) { \Log::info( "Failed authentication attempt due to country code mismatch ({$country}) for user: {$login}" ); $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); $attempt->deny(\App\AuthAttempt::REASON_GEOLOCATION); $attempt->notify(); - return $this->byebye($request, "Country code mismatch"); + 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 ($user->getSetting('2fa_enabled', false)) { $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); if (!$authAttempt->waitFor2FA()) { - return $this->byebye($request, "2fa failed"); + 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 + */ + + \Log::debug("Authentication attempt\n{$request->headers}"); + + $username = $this->normalizeUsername($request->headers->get('Php-Auth-User', "")); + $password = $request->headers->get('Php-Auth-Pw', null); + + 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 + */ + + \Log::debug("Authentication attempt\n{$request->headers}"); + + $password = $request->headers->get('Auth-Pass', null); + + try { + $user = $this->authorizeRequest( + $request->headers->get('Auth-User', null), + $password, + $request->headers->get('Client-Ip', null), + ); + } catch (\Exception $e) { + return $this->byebye($request, $e->getMessage()); + } // All checks passed switch ($request->headers->get('Auth-Protocol')) { case "imap": return $this->authenticateIMAP($request, $user->getSetting('guam_enabled', false), $password); case "smtp": return $this->authenticateSMTP($request, $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 ] ); \Log::debug("Response with headers:\n{$response->headers}"); 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 ] ); \Log::debug("Response with headers:\n{$response->headers}"); 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 ] ); \Log::debug("Response with headers:\n{$response->headers}"); return $response; } } diff --git a/src/routes/api.php b/src/routes/api.php index 036745d2..18f435a3 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,263 +1,264 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::post('signup/init', 'API\SignupController@init'); Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::post('companion/register', 'API\V4\CompanionAppsController@register'); Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm'); Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny'); Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details'); Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index'); Route::apiResource('domains', 'API\V4\DomainsController'); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/skus', 'API\V4\SkusController@domainSkus'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig'); Route::apiResource('groups', 'API\V4\GroupsController'); Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); Route::post('groups/{id}/config', 'API\V4\GroupsController@setConfig'); Route::apiResource('packages', 'API\V4\PackagesController'); Route::apiResource('resources', 'API\V4\ResourcesController'); Route::get('resources/{id}/status', 'API\V4\ResourcesController@status'); Route::post('resources/{id}/config', 'API\V4\ResourcesController@setConfig'); Route::apiResource('shared-folders', 'API\V4\SharedFoldersController'); Route::get('shared-folders/{id}/status', 'API\V4\SharedFoldersController@status'); Route::post('shared-folders/{id}/config', 'API\V4\SharedFoldersController@setConfig'); Route::apiResource('skus', 'API\V4\SkusController'); Route::apiResource('users', 'API\V4\UsersController'); Route::post('users/{id}/config', 'API\V4\UsersController@setConfig'); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', 'API\V4\WalletsController'); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); //Route::delete('payments', 'API\V4\PaymentsController@cancel'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::get('nginx', 'API\V4\NGINXController@authenticate'); + Route::get('nginx-httpauth', 'API\V4\NGINXController@httpauth'); Route::post('policy/greylist', 'API\V4\PolicyController@greylist'); Route::post('policy/ratelimit', 'API\V4\PolicyController@ratelimit'); Route::post('policy/spf', 'API\V4\PolicyController@senderPolicyFramework'); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', 'API\V4\Admin\DomainsController'); Route::get('domains/{id}/skus', 'API\V4\Admin\SkusController@domainSkus'); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('groups', 'API\V4\Admin\GroupsController'); Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); Route::apiResource('resources', 'API\V4\Admin\ResourcesController'); Route::apiResource('shared-folders', 'API\V4\Admin\SharedFoldersController'); Route::apiResource('skus', 'API\V4\Admin\SkusController'); Route::apiResource('users', 'API\V4\Admin\UsersController'); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', 'API\V4\Admin\WalletsController'); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', 'API\V4\Reseller\DomainsController'); Route::get('domains/{id}/skus', 'API\V4\Reseller\SkusController@domainSkus'); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); Route::apiResource('groups', 'API\V4\Reseller\GroupsController'); Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend'); Route::apiResource('invitations', 'API\V4\Reseller\InvitationsController'); Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); Route::post('payments', 'API\V4\Reseller\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments'); Route::apiResource('resources', 'API\V4\Reseller\ResourcesController'); Route::apiResource('shared-folders', 'API\V4\Reseller\SharedFoldersController'); Route::apiResource('skus', 'API\V4\Reseller\SkusController'); Route::apiResource('users', 'API\V4\Reseller\UsersController'); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', 'API\V4\Reseller\WalletsController'); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); } diff --git a/src/tests/Feature/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php index 1537cd6c..4277c38b 100644 --- a/src/tests/Feature/Controller/NGINXTest.php +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -1,166 +1,246 @@ getTestUser('john@kolab.org'); \App\CompanionApp::where('user_id', $john->id)->delete(); \App\AuthAttempt::where('user_id', $john->id)->delete(); $john->setSettings( [ // 'limit_geo' => json_encode(["CH"]), 'guam_enabled' => false, '2fa_enabled' => false ] ); $this->useServicesUrl(); } 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' => json_encode(["CH"]), 'guam_enabled' => false, '2fa_enabled' => false ] ); 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'); // 2-FA without device $john->setSettings( [ '2fa_enabled' => true, ] ); \App\CompanionApp::where('user_id', $john->id)->delete(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // 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'); } + + /** + * Test the httpauth webhook + */ + public function testNGINXHttpAuthHook(): void + { + $john = $this->getTestUser('john@kolab.org'); + + $response = $this->get("api/webhooks/nginx-httpauth"); + $response->assertStatus(403); + + $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(403); + + // Empty User + $modifiedHeaders = $headers; + $modifiedHeaders['Php-Auth-User'] = ""; + $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); + $response->assertStatus(403); + + // 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); + + + // 2-FA without device + $john->setSettings( + [ + '2fa_enabled' => true, + ] + ); + \App\CompanionApp::where('user_id', $john->id)->delete(); + + $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); + $response->assertStatus(403); + + // 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); + } }