Page MenuHomePhorge

D2674.1775212145.diff
No OneTemporary

Authored By
Unknown
Size
52 KB
Referenced Files
None
Subscribers
None

D2674.1775212145.diff

diff --git a/docker-compose.yml b/docker-compose.yml
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -74,11 +74,8 @@
build:
context: ./docker/nginx/
args:
- NGINX_AUTH_WEBHOOK: ${APP_DOMAIN}/api/webhooks/nginx
+ APP_WEBSITE_DOMAIN: ${APP_WEBSITE_DOMAIN:?err}
container_name: kolab-nginx
- depends_on:
- kolab:
- condition: service_healthy
hostname: nginx.hosted.com
image: kolab-nginx
network_mode: host
@@ -89,10 +86,8 @@
- /var/tmp
tty: true
volumes:
- - /etc/letsencrypt/:/etc/letsencrypt/:ro
- ./docker/certs/imap.hosted.com.cert:/etc/pki/tls/certs/imap.hosted.com.cert
- ./docker/certs/imap.hosted.com.key:/etc/pki/tls/private/imap.hosted.com.key
- - /sys/fs/cgroup:/sys/fs/cgroup:ro
openvidu:
build:
context: ./docker/openvidu/
diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile
--- a/docker/nginx/Dockerfile
+++ b/docker/nginx/Dockerfile
@@ -1,54 +1,25 @@
-FROM fedora:31
+FROM fedora:34
MAINTAINER Jeroen van Meeuwen <vanmeeuwen@kolabsys.com>
ENV container docker
-ENV SYSTEMD_PAGER=''
-
-ARG NGINX_AUTH_WEBHOOK
RUN dnf -y install \
--setopt 'tsflags=nodocs' \
- bash-completion \
- bind-utils \
- certbot \
- curl \
- dhcp-client \
- git \
- iproute \
- iptraf-ng \
- iputils \
- less \
- lsof \
- mtr \
- net-tools \
- NetworkManager \
- NetworkManager-tui \
- network-scripts \
nginx \
- nginx-mod-mail \
- nmap-ncat \
- openssh-clients \
- openssh-server \
- procps-ng \
- python3-certbot-nginx \
- strace \
- systemd-udev \
- tcpdump \
- telnet \
- traceroute \
- vim-enhanced \
- wget && \
+ nginx-mod-mail && \
dnf clean all
-RUN sed -i -r -e 's/^SELINUX=.*$/SELINUX=permissive/g' /etc/selinux/config 2>/dev/null || :
-
COPY nginx.conf /etc/nginx/nginx.conf
-RUN sed -i -r -e "s|^.*auth_http.*$| auth_http $NGINX_AUTH_WEBHOOK;|g" /etc/nginx/nginx.conf
+ARG APP_WEBSITE_DOMAIN
+RUN sed -i -r -e "s|^.*auth_http_header.*$| auth_http_header Host services.$APP_WEBSITE_DOMAIN;|g" /etc/nginx/nginx.conf
+
+# Forward request logs to Docker log collector
+RUN ln -sf /dev/stdout /var/log/nginx/access.log \
+ && ln -sf /dev/stderr /var/log/nginx/error.log
-RUN systemctl enable nginx
+STOPSIGNAL SIGTERM
-CMD ["/lib/systemd/systemd", "--system"]
-ENTRYPOINT "/lib/systemd/systemd"
+CMD ["nginx", "-g", "daemon off;"]
EXPOSE 110/tcp 143/tcp 993/tcp 995/tcp
diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf
--- a/docker/nginx/nginx.conf
+++ b/docker/nginx/nginx.conf
@@ -11,8 +11,9 @@
}
mail {
- server_name imap.hosted.com;
- auth_http 127.0.0.1:8000/api/webhooks/nginx;
+ server_name imap.hosted.com;
+ auth_http 127.0.0.1:8000/api/webhooks/nginx;
+ auth_http_header Host 127.0.0.1;
proxy_pass_error_message on;
diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -6,6 +6,7 @@
#APP_PASSPHRASE=
APP_PUBLIC_URL=
APP_DOMAIN=kolabnow.com
+APP_WEBSITE_DOMAIN=kolabnow.com
APP_THEME=default
APP_TENANT_ID=5
APP_LOCALE=en
diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php
new file mode 100644
--- /dev/null
+++ b/src/app/AuthAttempt.php
@@ -0,0 +1,194 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+use Iatstuti\Database\Support\NullableFields;
+use Carbon\Carbon;
+
+/**
+ * The eloquent definition of an AuthAttempt.
+ *
+ * An AuthAttempt represents an authenticaton attempt from an application/client.
+ */
+class AuthAttempt extends Model
+{
+ use NullableFields;
+
+ // No specific reason
+ public const REASON_NONE = '';
+ // Password mismatch
+ public const REASON_PASSWORD = 'password';
+ // Geolocation whitelist mismatch
+ public const REASON_GEOLOCATION = 'geolocation';
+
+ private const STATUS_ACCEPTED = 'ACCEPTED';
+ private const STATUS_DENIED = 'DENIED';
+
+ protected $nullable = [
+ 'reason',
+ ];
+
+ protected $fillable = [
+ 'ip',
+ 'user_id',
+ 'status',
+ 'reason',
+ 'expires_at',
+ 'last_seen',
+ ];
+
+ protected $casts = [
+ 'expires_at' => 'datetime',
+ 'last_seen' => 'datetime'
+ ];
+
+ /**
+ * Prepare a date for array / JSON serialization.
+ *
+ * Required to not omit timezone and match the format of update_at/created_at timestamps.
+ *
+ * @param \DateTimeInterface $date
+ * @return string
+ */
+ protected function serializeDate(\DateTimeInterface $date): string
+ {
+ return Carbon::instance($date)->toIso8601ZuluString('microseconds');
+ }
+
+ /**
+ * Returns true if the authentication attempt is accepted.
+ *
+ * @return bool
+ */
+ public function isAccepted(): bool
+ {
+ if ($this->status == self::STATUS_ACCEPTED && Carbon::now() < $this->expires_at) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if the authentication attempt is denied.
+ *
+ * @return bool
+ */
+ public function isDenied(): bool
+ {
+ return ($this->status == self::STATUS_DENIED);
+ }
+
+ /**
+ * Accept the authentication attempt.
+ */
+ public function accept($reason = AuthAttempt::REASON_NONE)
+ {
+ $this->expires_at = Carbon::now()->addHours(8);
+ $this->status = self::STATUS_ACCEPTED;
+ $this->reason = $reason;
+ $this->save();
+ }
+
+ /**
+ * Deny the authentication attempt.
+ */
+ public function deny($reason = AuthAttempt::REASON_NONE)
+ {
+ $this->status = self::STATUS_DENIED;
+ $this->reason = $reason;
+ $this->save();
+ }
+
+ /**
+ * Notify the user of this authentication attempt.
+ *
+ * @return bool false if there was no means to notify
+ */
+ public function notify(): bool
+ {
+ return \App\CompanionApp::notifyUser($this->user_id, ['token' => $this->id]);
+ }
+
+ /**
+ * Notify the user and wait for a confirmation.
+ */
+ private function notifyAndWait()
+ {
+ if (!$this->notify()) {
+ //FIXME if the webclient can confirm too we don't need to abort here.
+ \Log::warning("There is no 2fa device to notify.");
+ return false;
+ }
+
+ \Log::debug("Authentication attempt: {$this->id}");
+
+ $confirmationTimeout = 120;
+ $timeout = Carbon::now()->addSeconds($confirmationTimeout);
+
+ do {
+ if ($this->isDenied()) {
+ \Log::debug("The authentication attempt was denied {$this->id}");
+ return false;
+ }
+
+ if ($this->isAccepted()) {
+ \Log::debug("The authentication attempt was accepted {$this->id}");
+ return true;
+ }
+
+ if ($timeout < Carbon::now()) {
+ \Log::debug("The authentication attempt timed-out: {$this->id}");
+ return false;
+ }
+
+ sleep(2);
+ $this->refresh();
+ } while (true);
+ }
+
+ /**
+ * Record a new authentication attempt or update an existing one.
+ *
+ * @param \App\User $user The user attempting to authenticate.
+ * @param string $clientIP The ip the authentication attempt is coming from.
+ *
+ * @return \App\AuthAttempt
+ */
+ public static function recordAuthAttempt(\App\User $user, $clientIP)
+ {
+ $authAttempt = \App\AuthAttempt::where('ip', $clientIP)->where('user_id', $user->id)->first();
+
+ if (!$authAttempt) {
+ $authAttempt = new \App\AuthAttempt();
+ $authAttempt->ip = $clientIP;
+ $authAttempt->user_id = $user->id;
+ }
+
+ $authAttempt->last_seen = Carbon::now();
+ $authAttempt->save();
+
+ return $authAttempt;
+ }
+
+ /**
+ * Trigger a notification if necessary and wait for confirmation.
+ *
+ * @return bool Returns true if the attempt is accepted on confirmation
+ */
+ public function waitFor2FA(): bool
+ {
+ if ($this->isAccepted()) {
+ return true;
+ }
+ if ($this->isDenied()) {
+ return false;
+ }
+
+ if (!$this->notifyAndWait()) {
+ return false;
+ }
+
+ return $this->isAccepted();
+ }
+}
diff --git a/src/app/CompanionApp.php b/src/app/CompanionApp.php
new file mode 100644
--- /dev/null
+++ b/src/app/CompanionApp.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a CompanionApp.
+ *
+ * A CompanionApp is an kolab companion app that the user registered
+ */
+class CompanionApp extends Model
+{
+ protected $fillable = [
+ 'name',
+ 'user_id',
+ 'device_id',
+ 'notification_token',
+ 'mfa_enabled',
+ ];
+
+ /**
+ * Send a notification via firebase.
+ *
+ * @param array $deviceIds A list of device id's to send the notification to
+ * @param array $data The data to include in the notification.
+ *
+ * @throws \Exception on notification failure
+ * @return bool true if a notification has been sent
+ */
+ private static function pushFirebaseNotification($deviceIds, $data): bool
+ {
+ \Log::debug("sending notification to " . var_export($deviceIds, true));
+ $apiKey = \config('firebase.api_key');
+
+ $client = new \GuzzleHttp\Client(
+ [
+ 'verify' => \config('firebase.api_verify_tls')
+ ]
+ );
+ $response = $client->request(
+ 'POST',
+ \config('firebase.api_url'),
+ [
+ 'headers' => [
+ 'Authorization' => "key={$apiKey}",
+ ],
+ 'json' => [
+ 'registration_ids' => $deviceIds,
+ 'data' => $data
+ ]
+ ]
+ );
+
+
+ if ($response->getStatusCode() != 200) {
+ throw new \Exception('FCM Send Error: ' . $response->getStatusCode());
+ }
+ return true;
+ }
+
+ /**
+ * Send a notification to a user.
+ *
+ * @throws \Exception on notification failure
+ * @return bool true if a notification has been sent
+ */
+ public static function notifyUser($userId, $data): bool
+ {
+ $notificationTokens = \App\CompanionApp::where('user_id', $userId)
+ ->where('mfa_enabled', true)
+ ->pluck('notification_token')
+ ->all();
+
+ if (empty($notificationTokens)) {
+ \Log::debug("There is no 2fa device to notify.");
+ return false;
+ }
+
+ self::pushFirebaseNotification($notificationTokens, $data);
+ return true;
+ }
+}
diff --git a/src/app/Console/Commands/AuthAttempt/DeleteCommand.php b/src/app/Console/Commands/AuthAttempt/DeleteCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/AuthAttempt/DeleteCommand.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Console\Commands\AuthAttempt;
+
+use App\Console\ObjectDeleteCommand;
+
+class DeleteCommand extends ObjectDeleteCommand
+{
+ protected $dangerous = false;
+ protected $hidden = false;
+
+ protected $objectClass = \App\AuthAttempt::class;
+ protected $objectName = 'authattempt';
+ protected $objectTitle = 'id';
+}
diff --git a/src/app/Console/Commands/AuthAttempt/ListCommand.php b/src/app/Console/Commands/AuthAttempt/ListCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/AuthAttempt/ListCommand.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Console\Commands\AuthAttempt;
+
+use App\Console\Command;
+use App\AuthAttempt;
+
+class ListCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'authattempt:list';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'List auth attempts';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $authAttempts = AuthAttempt::orderBy('last_seen');
+
+ $authAttempts->each(
+ function ($authAttempt) {
+ $this->info($authAttempt->toJson(JSON_PRETTY_PRINT));
+ }
+ );
+ }
+}
diff --git a/src/app/Console/Commands/AuthAttempt/PurgeCommand.php b/src/app/Console/Commands/AuthAttempt/PurgeCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/AuthAttempt/PurgeCommand.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Console\Commands\AuthAttempt;
+
+use App\Console\Command;
+use App\AuthAttempt;
+use Carbon\Carbon;
+
+class PurgeCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'authattempt:purge';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Purge old AuthAttempts from the database';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $cutoff = Carbon::now()->subDays(30);
+ AuthAttempt::where('updated_at', '<', $cutoff)
+ ->delete();
+ }
+}
diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php
--- a/src/app/Http/Controllers/API/AuthController.php
+++ b/src/app/Http/Controllers/API/AuthController.php
@@ -157,7 +157,7 @@
if ($tokenResponse->getStatusCode() != 200) {
if (isset($data->error) && $data->error == 'secondfactor') {
- $errors = ['secondfactor' => $data['error_description']];
+ $errors = ['secondfactor' => $data->error_description];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
diff --git a/src/app/Http/Controllers/API/V4/AuthAttemptsController.php b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\AuthAttempt;
+use App\Http\Controllers\Controller;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Http\Request;
+
+class AuthAttemptsController extends Controller
+{
+
+ /**
+ * Confirm the authentication attempt.
+ *
+ * @param string $id Id of AuthAttempt attempt
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function confirm($id)
+ {
+ $authAttempt = AuthAttempt::find($id);
+ if (!$authAttempt) {
+ return $this->errorResponse(404);
+ }
+
+ $user = $this->guard()->user();
+ if ($user->id != $authAttempt->user_id) {
+ return $this->errorResponse(403);
+ }
+
+ \Log::debug("Confirm on {$authAttempt->id}");
+ $authAttempt->accept();
+ return response()->json([], 200);
+ }
+
+ /**
+ * Deny the authentication attempt.
+ *
+ * @param string $id Id of AuthAttempt attempt
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function deny($id)
+ {
+ $authAttempt = AuthAttempt::find($id);
+ if (!$authAttempt) {
+ return $this->errorResponse(404);
+ }
+
+ $user = $this->guard()->user();
+ if ($user->id != $authAttempt->user_id) {
+ return $this->errorResponse(403);
+ }
+
+ \Log::debug("Deny on {$authAttempt->id}");
+ $authAttempt->deny();
+ return response()->json([], 200);
+ }
+
+ /**
+ * Return details of authentication attempt.
+ *
+ * @param string $id Id of AuthAttempt attempt
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function details($id)
+ {
+ $authAttempt = AuthAttempt::find($id);
+ if (!$authAttempt) {
+ return $this->errorResponse(404);
+ }
+
+ $user = $this->guard()->user();
+ if ($user->id != $authAttempt->user_id) {
+ return $this->errorResponse(403);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'username' => $user->email,
+ 'country' => \App\Utils::countryForIP($authAttempt->ip),
+ 'entry' => $authAttempt->toArray()
+ ]);
+ }
+
+ /**
+ * Listing of client authAttempts.
+ *
+ * All authAttempt attempts from the current user
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index(Request $request)
+ {
+ $user = $this->guard()->user();
+
+ $pageSize = 10;
+ $page = intval($request->input('page')) ?: 1;
+ $hasMore = false;
+
+ $result = \App\AuthAttempt::where('user_id', $user->id)
+ ->orderBy('updated_at', 'desc')
+ ->limit($pageSize + 1)
+ ->offset($pageSize * ($page - 1))
+ ->get();
+
+ if (count($result) > $pageSize) {
+ $result->pop();
+ $hasMore = true;
+ }
+
+ $result = $result->map(function ($authAttempt) {
+ return $authAttempt->toArray();
+ });
+
+ return response()->json($result);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/CompanionAppsController.php b/src/app/Http/Controllers/API/V4/CompanionAppsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/CompanionAppsController.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Validator;
+
+class CompanionAppsController extends Controller
+{
+ /**
+ * Register a companion app.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function register(Request $request)
+ {
+ $user = $this->guard()->user();
+
+ $v = Validator::make(
+ $request->all(),
+ [
+ 'notificationToken' => 'required|min:4|max:512',
+ 'deviceId' => 'required|min:4|max:64',
+ ]
+ );
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ $notificationToken = $request->notificationToken;
+ $deviceId = $request->deviceId;
+
+ \Log::info("Registering app. Notification token: {$notificationToken} Device id: {$deviceId}");
+
+ $app = \App\CompanionApp::where('device_id', $deviceId)->first();
+ if (!$app) {
+ $app = new \App\CompanionApp();
+ $app->user_id = $user->id;
+ $app->device_id = $deviceId;
+ $app->mfa_enabled = true;
+ } else {
+ //FIXME this allows a user to probe for another users deviceId
+ if ($app->user_id != $user->id) {
+ \Log::warning("User mismatch on device registration. Expected {$user->id} but found {$app->user_id}");
+ return $this->errorResponse(403);
+ }
+ }
+
+ $app->notification_token = $notificationToken;
+ $app->save();
+
+ return response()->json(['status' => 'success']);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/NGINXController.php
@@ -0,0 +1,203 @@
+<?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
+{
+ /**
+ * 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");
+ \Log::debug($request->headers);
+
+ $login = $request->headers->get('Auth-User', null);
+
+ if (empty($login)) {
+ return $this->byebye($request, "Empty login");
+ }
+
+ // validate password, otherwise bye bye
+ $password = $request->headers->get('Auth-Pass', null);
+
+ if (empty($password)) {
+ return $this->byebye($request, "Empty password");
+ }
+
+ $clientIP = $request->headers->get('Client-Ip', null);
+
+ if (empty($clientIP)) {
+ return $this->byebye($request, "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");
+ }
+
+ // 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");
+ }
+
+ // 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");
+ }
+ }
+
+ // 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");
+ }
+ }
+
+ // 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/config/firebase.php b/src/config/firebase.php
new file mode 100644
--- /dev/null
+++ b/src/config/firebase.php
@@ -0,0 +1,7 @@
+<?php
+ return [
+ /* api_key available in: Firebase Console -> Project Settings -> CLOUD MESSAGING -> Server key*/
+ 'api_key' => env('FIREBASE_API_KEY'),
+ 'api_url' => env('FIREBASE_API_URL', 'https://fcm.googleapis.com/fcm/send'),
+ 'api_verify_tls' => (bool) env('FIREBASE_API_VERIFY_TLS', true)
+ ];
diff --git a/src/config/imap.php b/src/config/imap.php
--- a/src/config/imap.php
+++ b/src/config/imap.php
@@ -5,5 +5,8 @@
'admin_login' => env('IMAP_ADMIN_LOGIN', 'cyrus-admin'),
'admin_password' => env('IMAP_ADMIN_PASSWORD', null),
'verify_peer' => env('IMAP_VERIFY_PEER', true),
- 'verify_host' => env('IMAP_VERIFY_HOST', true)
+ 'verify_host' => env('IMAP_VERIFY_HOST', true),
+ 'host' => env('IMAP_HOST', '127.0.0.1'),
+ 'imap_port' => env('IMAP_PORT', 12143),
+ 'guam_port' => env('IMAP_GUAM_PORT', 9143),
];
diff --git a/src/config/smtp.php b/src/config/smtp.php
new file mode 100644
--- /dev/null
+++ b/src/config/smtp.php
@@ -0,0 +1,6 @@
+<?php
+
+return [
+ 'host' => env('SMTP_HOST', '127.0.0.1'),
+ 'port' => env('SMTP_PORT', 10465),
+];
diff --git a/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php b/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php
@@ -0,0 +1,46 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class CreateAuthAttemptsTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('auth_attempts', function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('user_id');
+ $table->string('ip', 36);
+ $table->string('status', 36)->default('NEW');
+ $table->string('reason', 36)->nullable();
+ $table->datetime('expires_at')->nullable();
+ $table->datetime('last_seen')->nullable();
+ $table->timestamps();
+
+ $table->index('updated_at');
+ $table->unique(['user_id', 'ip']);
+
+ $table->foreign('user_id')
+ ->references('id')->on('users')
+ ->onDelete('cascade')
+ ->onUpdate('cascade');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('auth_attempts');
+ }
+}
diff --git a/src/database/migrations/2021_05_05_134357_create_companion_apps_table.php b/src/database/migrations/2021_05_05_134357_create_companion_apps_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_05_05_134357_create_companion_apps_table.php
@@ -0,0 +1,45 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class CreateCompanionAppsTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('companion_apps', function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('user_id');
+ // Seems to grow over time, no clear specification.
+ // Typically below 200 bytes, but some mention up to 350 bytes.
+ $table->string('notification_token', 512)->nullable();
+ // 16 byte for android, 36 for ios. May change over tyme
+ $table->string('device_id', 64);
+ $table->string('name')->nullable();
+ $table->boolean('mfa_enabled');
+ $table->timestamps();
+
+ $table->foreign('user_id')
+ ->references('id')->on('users')
+ ->onDelete('cascade')
+ ->onUpdate('cascade');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('companion_apps');
+ }
+}
diff --git a/src/database/seeds/local/UserSeeder.php b/src/database/seeds/local/UserSeeder.php
--- a/src/database/seeds/local/UserSeeder.php
+++ b/src/database/seeds/local/UserSeeder.php
@@ -100,7 +100,10 @@
'first_name' => 'Edward',
'last_name' => 'Flanders',
'currency' => 'USD',
- 'country' => 'US'
+ 'country' => 'US',
+ // 'limit_geo' => json_encode(["CH"]),
+ 'guam_enabled' => false,
+ '2fa_enabled' => true
]
);
diff --git a/src/phpstan.neon b/src/phpstan.neon
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -2,7 +2,6 @@
- ./vendor/nunomaduro/larastan/extension.neon
parameters:
ignoreErrors:
- - '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable#'
- '#Access to an undefined property [a-zA-Z\\]+::\$pivot#'
- '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenantContext\(\)#'
- '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withObjectTenantContext\(\)#'
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -54,6 +54,8 @@
}
);
+
+
Route::group(
[
'domain' => \config('app.website_domain'),
@@ -61,6 +63,13 @@
'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::class);
Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm');
Route::get('domains/{id}/status', 'API\V4\DomainsController@status');
@@ -147,12 +156,13 @@
Route::group(
[
'domain' => 'services.' . \config('app.website_domain'),
- 'prefix' => $prefix . 'api/webhooks/policy'
+ 'prefix' => $prefix . 'api/webhooks'
],
function () {
- Route::post('greylist', 'API\V4\PolicyController@greylist');
- Route::post('ratelimit', 'API\V4\PolicyController@ratelimit');
- Route::post('spf', 'API\V4\PolicyController@senderPolicyFramework');
+ Route::get('nginx', 'API\V4\NGINXController@authenticate');
+ Route::post('policy/greylist', 'API\V4\PolicyController@greylist');
+ Route::post('policy/ratelimit', 'API\V4\PolicyController@ratelimit');
+ Route::post('policy/spf', 'API\V4\PolicyController@senderPolicyFramework');
}
);
}
diff --git a/src/tests/Feature/AuthAttemptTest.php b/src/tests/Feature/AuthAttemptTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/AuthAttemptTest.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\AuthAttempt;
+use Tests\TestCase;
+
+class AuthAttemptTest extends TestCase
+{
+
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('jane@kolabnow.com');
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('jane@kolabnow.com');
+
+ parent::tearDown();
+ }
+
+ public function testRecord(): void
+ {
+ $user = $this->getTestUser('jane@kolabnow.com');
+ $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1");
+ $this->assertEquals($authAttempt->user_id, $user->id);
+ $this->assertEquals($authAttempt->ip, "10.0.0.1");
+ $authAttempt->refresh();
+ $this->assertEquals($authAttempt->status, "NEW");
+
+ $authAttempt2 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1");
+ $this->assertEquals($authAttempt->id, $authAttempt2->id);
+
+ $authAttempt3 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.2");
+ $this->assertNotEquals($authAttempt->id, $authAttempt3->id);
+ }
+}
diff --git a/src/tests/Feature/Console/AuthAttempt/DeleteTest.php b/src/tests/Feature/Console/AuthAttempt/DeleteTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Console/AuthAttempt/DeleteTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Feature\Console\AuthAttempt;
+
+use App\AuthAttempt;
+use Tests\TestCase;
+
+class DeleteTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test command runs
+ */
+ public function testHandle(): void
+ {
+ // Warning: We're not using artisan() here, as this will not
+ // allow us to test "empty output" cases
+
+ $user = $this->getTestUser('john@kolab.org');
+ $authAttempt = AuthAttempt::recordAuthAttempt($user, "10.0.0.1");
+ $code = \Artisan::call("authattempt:delete {$authAttempt->id}");
+ $output = trim(\Artisan::output());
+ $this->assertSame(0, $code);
+ $this->assertTrue(!AuthAttempt::find($authAttempt->id));
+
+ // AuthAttempt not existing
+ $code = \Artisan::call("authattempt:delete 999");
+ $output = trim(\Artisan::output());
+ $this->assertSame(1, $code);
+ $this->assertSame("No such authattempt 999", $output);
+ }
+}
diff --git a/src/tests/Feature/Console/AuthAttempt/ListTest.php b/src/tests/Feature/Console/AuthAttempt/ListTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Console/AuthAttempt/ListTest.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Tests\Feature\Console\AuthAttempt;
+
+use App\AuthAttempt;
+use Tests\TestCase;
+
+class ListTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ AuthAttempt::truncate();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ AuthAttempt::truncate();
+ parent::tearDown();
+ }
+
+ /**
+ * Test command runs
+ */
+ public function testHandle(): void
+ {
+ // Warning: We're not using artisan() here, as this will not
+ // allow us to test "empty output" cases
+ $code = \Artisan::call('authattempt:list');
+ $output = trim(\Artisan::output());
+ $this->assertSame(0, $code);
+ $this->assertSame('', $output);
+
+ $user = $this->getTestUser('john@kolab.org');
+ $authAttempt = AuthAttempt::recordAuthAttempt($user, "10.0.0.1");
+ //For up-to date timestamps and whatnot
+ $authAttempt->refresh();
+
+ $code = \Artisan::call("authattempt:list");
+ $output = trim(\Artisan::output());
+ $this->assertSame(0, $code);
+
+ $this->assertSame($authAttempt->toJson(JSON_PRETTY_PRINT), $output);
+ }
+}
diff --git a/src/tests/Feature/Console/AuthAttempt/PurgeTest.php b/src/tests/Feature/Console/AuthAttempt/PurgeTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Console/AuthAttempt/PurgeTest.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Tests\Feature\Console\AuthAttempt;
+
+use Carbon\Carbon;
+use App\AuthAttempt;
+use Tests\TestCase;
+
+class PurgeTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ AuthAttempt::truncate();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ AuthAttempt::truncate();
+ parent::tearDown();
+ }
+
+ /**
+ * Test command runs
+ */
+ public function testHandle(): void
+ {
+ // Warning: We're not using artisan() here, as this will not
+ // allow us to test "empty output" cases
+ $cutoff = Carbon::now()->subDays(30);
+
+ $user = $this->getTestUser('john@kolab.org');
+
+ $authAttempt1 = AuthAttempt::recordAuthAttempt($user, "10.0.0.1");
+ $authAttempt1->refresh();
+ $authAttempt1->updated_at = $cutoff->copy()->addDays(1);
+ $authAttempt1->save(['timestamps' => false]);
+
+ $authAttempt2 = AuthAttempt::recordAuthAttempt($user, "10.0.0.2");
+ $authAttempt2->refresh();
+ $authAttempt2->updated_at = $cutoff->copy()->subDays(1);
+ $authAttempt2->save(['timestamps' => false]);
+
+ $code = \Artisan::call('authattempt:purge');
+ $this->assertSame(0, $code);
+
+ $list = AuthAttempt::all();
+ $this->assertCount(1, $list);
+ $this->assertSame($authAttempt1->id, $list[0]->id);
+ }
+}
diff --git a/src/tests/Feature/Controller/AuthAttemptsTest.php b/src/tests/Feature/Controller/AuthAttemptsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/AuthAttemptsTest.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\User;
+use App\AuthAttempt;
+use Tests\TestCase;
+
+class AuthAttemptsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
+ $this->deleteTestDomain('userscontroller.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
+ $this->deleteTestDomain('userscontroller.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test cofirm (POST /api/v4/auth-attempts/<authAttempt>/confirm)
+ */
+ public function testAccept(): void
+ {
+ $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1");
+
+ $response = $this->actingAs($user)->post("api/v4/auth-attempts/{$authAttempt->id}/confirm");
+ $response->assertStatus(200);
+ $authAttempt->refresh();
+ $this->assertTrue($authAttempt->isAccepted());
+
+ // wrong user
+ $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
+ $response = $this->actingAs($user2)->post("api/v4/auth-attempts/{$authAttempt->id}/confirm");
+ $response->assertStatus(403);
+
+ // wrong id
+ $response = $this->actingAs($user)->post("api/v4/auth-attempts/9999/confirm");
+ $response->assertStatus(404);
+ }
+
+
+ /**
+ * Test deny (POST /api/v4/auth-attempts/<authAttempt>/deny)
+ */
+ public function testDeny(): void
+ {
+ $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1");
+
+ $response = $this->actingAs($user)->post("api/v4/auth-attempts/{$authAttempt->id}/deny");
+ $response->assertStatus(200);
+ $authAttempt->refresh();
+ $this->assertTrue($authAttempt->isDenied());
+
+ // wrong user
+ $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
+ $response = $this->actingAs($user2)->post("api/v4/auth-attempts/{$authAttempt->id}/deny");
+ $response->assertStatus(403);
+
+ // wrong id
+ $response = $this->actingAs($user)->post("api/v4/auth-attempts/9999/deny");
+ $response->assertStatus(404);
+ }
+
+
+ /**
+ * Test details (GET /api/v4/auth-attempts/<authAttempt>/details)
+ */
+ public function testDetails(): void
+ {
+ $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1");
+
+ $response = $this->actingAs($user)->get("api/v4/auth-attempts/{$authAttempt->id}/details");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $authAttempt->refresh();
+
+ $this->assertEquals($user->email, $json['username']);
+ $this->assertEquals($authAttempt->ip, $json['entry']['ip']);
+ $this->assertEquals(json_encode($authAttempt->updated_at), "\"" . $json['entry']['updated_at'] . "\"");
+ $this->assertEquals("CH", $json['country']);
+
+ // wrong user
+ $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
+ $response = $this->actingAs($user2)->get("api/v4/auth-attempts/{$authAttempt->id}/details");
+ $response->assertStatus(403);
+
+ // wrong id
+ $response = $this->actingAs($user)->get("api/v4/auth-attempts/9999/details");
+ $response->assertStatus(404);
+ }
+
+
+ /**
+ * Test list (GET /api/v4/auth-attempts)
+ */
+ public function testList(): void
+ {
+ $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1");
+ $authAttempt2 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.2");
+
+ $response = $this->actingAs($user)->get("api/v4/auth-attempts");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertTrue(in_array($json[0]['id'], [$authAttempt->id, $authAttempt2->id]));
+ $this->assertTrue(in_array($json[1]['id'], [$authAttempt->id, $authAttempt2->id]));
+ $this->assertTrue($json[0]['id'] != $json[1]['id']);
+ }
+}
diff --git a/src/tests/Feature/Controller/CompanionAppsTest.php b/src/tests/Feature/Controller/CompanionAppsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/CompanionAppsTest.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\User;
+use App\CompanionApp;
+use Tests\TestCase;
+
+class CompanionAppsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
+ $this->deleteTestDomain('userscontroller.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('UsersControllerTest1@userscontroller.com');
+ $this->deleteTestDomain('userscontroller.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test registering the app
+ */
+ public function testRegister(): void
+ {
+ $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com');
+
+ $notificationToken = "notificationToken";
+ $deviceId = "deviceId";
+
+ $response = $this->actingAs($user)->post(
+ "api/v4/companion/register",
+ ['notificationToken' => $notificationToken, 'deviceId' => $deviceId]
+ );
+
+ $response->assertStatus(200);
+
+ $companionApp = \App\CompanionApp::where('device_id', $deviceId)->first();
+ $this->assertTrue($companionApp != null);
+ $this->assertEquals($deviceId, $companionApp->device_id);
+ $this->assertEquals($notificationToken, $companionApp->notification_token);
+
+ // Test a token update
+ $notificationToken = "notificationToken2";
+ $response = $this->actingAs($user)->post(
+ "api/v4/companion/register",
+ ['notificationToken' => $notificationToken, 'deviceId' => $deviceId]
+ );
+
+ $response->assertStatus(200);
+
+ $companionApp->refresh();
+ $this->assertEquals($notificationToken, $companionApp->notification_token);
+
+ // Failing input valdiation
+ $response = $this->actingAs($user)->post(
+ "api/v4/companion/register",
+ []
+ );
+ $response->assertStatus(422);
+
+ // Other users device
+ $user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com');
+ $response = $this->actingAs($user2)->post(
+ "api/v4/companion/register",
+ ['notificationToken' => $notificationToken, 'deviceId' => $deviceId]
+ );
+ $response->assertStatus(403);
+ }
+}
diff --git a/src/tests/Feature/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/NGINXTest.php
@@ -0,0 +1,166 @@
+<?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' => 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');
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 10:29 AM (1 d, 4 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822500
Default Alt Text
D2674.1775212145.diff (52 KB)

Event Timeline