Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117799137
D2674.1775265996.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
52 KB
Referenced Files
None
Subscribers
None
D2674.1775265996.diff
View Options
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
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 1:26 AM (11 h, 7 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822500
Default Alt Text
D2674.1775265996.diff (52 KB)
Attached To
Mode
D2674: NGINX Controller, 2fa for client connections and companion app support
Attached
Detach File
Event Timeline