diff --git a/docker-compose.yml b/docker-compose.yml index e83e61a4..6eaf436e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,194 +1,189 @@ version: '3' services: coturn: container_name: kolab-coturn environment: - DB_NAME=${OPENVIDU_COTURN_REDIS_DATABASE} - DB_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD} - REDIS_IP=${OPENVIDU_COTURN_REDIS_IP} - TURN_PUBLIC_IP=${OPENVIDU_COTURN_IP} - TURN_LISTEN_PORT=3478 hostname: sturn.mgmt.com image: openvidu/openvidu-coturn:1.0.0 network_mode: host restart: on-failure tty: true kolab: build: context: ./docker/kolab/ container_name: kolab depends_on: - mariadb extra_hosts: - "kolab.mgmt.com:127.0.0.1" environment: - DB_HOST=${DB_HOST} - DB_ROOT_PASSWORD=Welcome2KolabSystems healthcheck: interval: 10s test: test -f /tmp/kolab-init.done timeout: 5s retries: 30 hostname: kolab.mgmt.com image: kolab network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - /etc/letsencrypt/:/etc/letsencrypt/:ro - ./docker/certs/ca.cert:/etc/pki/tls/certs/ca.cert:ro - ./docker/certs/ca.cert:/etc/pki/ca-trust/source/anchors/ca.cert:ro - ./docker/certs/kolab.hosted.com.cert:/etc/pki/tls/certs/kolab.hosted.com.cert - ./docker/certs/kolab.hosted.com.key:/etc/pki/tls/certs/kolab.hosted.com.key - ./docker/certs/kolab.mgmt.com.cert:/etc/pki/tls/certs/kolab.mgmt.com.cert - ./docker/certs/kolab.mgmt.com.key:/etc/pki/tls/certs/kolab.mgmt.com.key - ./docker/kolab/utils:/root/utils:ro - ./src/.env:/.dockerenv:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro kurento-media-server: build: context: ./docker/kurento-media-server/ container_name: kolab-kurento-media-server environment: - GST_DEBUG=3,Kurento*:4,kms*:4,sdp*:4,webrtc*:4,*rtpendpoint:4,rtp*handler:4,rtpsynchronizer:4,agnosticbin:4 hostname: kurento-media-server.hosted.com image: apheleia/kurento-media-server:6.15.0 network_mode: host mariadb: container_name: kolab-mariadb environment: MYSQL_ROOT_PASSWORD: Welcome2KolabSystems TZ: "+02:00" healthcheck: interval: 10s test: test -e /var/run/mysqld/mysqld.sock timeout: 5s retries: 30 image: mariadb network_mode: host nginx: 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 tmpfs: - /run - /tmp - /var/run - /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/ container_name: kolab-openvidu depends_on: - kurento-media-server environment: - APP_DOMAIN=${APP_DOMAIN} - CERTIFICATE_TYPE=letsencrypt - COTURN_IP=${OPENVIDU_COTURN_IP} - COTURN_REDIS_DBNAME=${OPENVIDU_COTURN_REDIS_DATABASE} - COTURN_REDIS_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD} - COTURN_REDIS_IP=${OPENVIDU_COTURN_REDIS_IP} - DOMAIN_OR_PUBLIC_IP=${OPENVIDU_PUBLIC_IP} - SERVER_PORT=${OPENVIDU_SERVER_PORT} - KMS_STUN_IP=${OPENVIDU_COTURN_IP} - KMS_STUN_PORT=3478 - KMS_URIS=["ws://localhost:8888/kurento", "ws://localhost:8889/kurento"] - OPENVIDU_SECRET=${OPENVIDU_API_PASSWORD} - OPENVIDU_WEBHOOK=${OPENVIDU_WEBHOOK} - OPENVIDU_WEBHOOK_ENDPOINT=${OPENVIDU_WEBHOOK_ENDPOINT} - SERVER_SSL_ENABLED=false hostname: openvidu.hosted.com image: apheleia/openvidu:2.18.0 network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - /etc/letsencrypt/:/etc/letsencrypt/:ro pdns-sql: build: context: ./docker/pdns-sql/ container_name: kolab-pdns-sql depends_on: - mariadb hostname: pdns-sql image: apheleia/kolab-pdns-sql network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - /sys/fs/cgroup:/sys/fs/cgroup:ro proxy: build: context: ./docker/proxy/ container_name: kolab-proxy hostname: kanarip.internet-box.ch image: kolab-proxy network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - ./docker/certs/:/etc/certs/:ro - /etc/letsencrypt/:/etc/letsencrypt/:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro redis: build: context: ./docker/redis/ container_name: kolab-redis hostname: redis image: redis network_mode: host volumes: - ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro swoole: build: context: ./docker/swoole/ container_name: kolab-swoole image: apheleia/swoole:4.6.x worker: build: context: ./docker/worker/ container_name: kolab-worker depends_on: - kolab hostname: worker image: kolab-worker network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - ./src:/home/worker/src.orig:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile index 55f55686..3a4459f4 100644 --- a/docker/nginx/Dockerfile +++ b/docker/nginx/Dockerfile @@ -1,54 +1,25 @@ -FROM fedora:31 +FROM fedora:34 MAINTAINER Jeroen van Meeuwen 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 index ca6d7a9d..cc288816 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -1,72 +1,73 @@ user nginx; worker_processes auto; error_log /var/log/nginx/error.log debug; pid /run/nginx.pid; # Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. include /usr/share/nginx/modules/*.conf; events { worker_connections 1024; } 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; server { listen 143; protocol imap; proxy on; starttls on; ssl_certificate /etc/pki/tls/certs/imap.hosted.com.cert; ssl_certificate_key /etc/pki/tls/private/imap.hosted.com.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers HIGH:!aNULL:!MD5; } server { listen 465 ssl; protocol smtp; proxy on; ssl_certificate /etc/pki/tls/certs/imap.hosted.com.cert; ssl_certificate_key /etc/pki/tls/private/imap.hosted.com.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers HIGH:!aNULL:!MD5; } server { listen 587; protocol smtp; proxy on; starttls on; ssl_certificate /etc/pki/tls/certs/imap.hosted.com.cert; ssl_certificate_key /etc/pki/tls/private/imap.hosted.com.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers HIGH:!aNULL:!MD5; } server { listen 993 ssl; protocol imap; proxy on; ssl_certificate /etc/pki/tls/certs/imap.hosted.com.cert; ssl_certificate_key /etc/pki/tls/private/imap.hosted.com.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers HIGH:!aNULL:!MD5; } } diff --git a/src/.env.example b/src/.env.example index 70f2e804..cadae2fd 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,166 +1,167 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 #APP_PASSPHRASE= APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com +APP_WEBSITE_DOMAIN=kolabnow.com APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en APP_LOCALES= APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 OPENEXCHANGERATES_API_KEY="from openexchangerates.org" MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:11993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" OPENVIDU_API_PASSWORD=MY_SECRET OPENVIDU_API_URL=http://localhost:8080/api/ OPENVIDU_API_USERNAME=OPENVIDUAPP OPENVIDU_API_VERIFY_TLS=true OPENVIDU_COTURN_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_DATABASE=2 OPENVIDU_COTURN_REDIS_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_PASSWORD=turn # Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL OPENVIDU_PUBLIC_IP=127.0.0.1 OPENVIDU_PUBLIC_PORT=3478 OPENVIDU_SERVER_PORT=8080 OPENVIDU_WEBHOOK=true OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu # "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/ #OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged] #OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"] PGP_ENABLED= PGP_BINARY= PGP_AGENT= PGP_GPGCONF= PGP_LENGTH= REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 SWOOLE_HOT_RELOAD_ENABLE=true SWOOLE_HTTP_ACCESS_LOG=true SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 SWOOLE_HTTP_REACTOR_NUM=1 SWOOLE_HTTP_WEBSOCKET=true SWOOLE_HTTP_WORKER_NUM=1 SWOOLE_OB_OUTPUT=true PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS="replyto@example.com" MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_ASSET_PATH='/' MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" # Generate with ./artisan passport:client --password #PASSPORT_PROXY_OAUTH_CLIENT_ID= #PASSPORT_PROXY_OAUTH_CLIENT_SECRET= PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= COMPANY_NAME= COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php new file mode 100644 index 00000000..ebafe212 --- /dev/null +++ b/src/app/AuthAttempt.php @@ -0,0 +1,194 @@ + '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 index 00000000..1720223b --- /dev/null +++ b/src/app/CompanionApp.php @@ -0,0 +1,83 @@ + \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 index 00000000..a8c18622 --- /dev/null +++ b/src/app/Console/Commands/AuthAttempt/DeleteCommand.php @@ -0,0 +1,15 @@ +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 index 00000000..82033860 --- /dev/null +++ b/src/app/Console/Commands/AuthAttempt/PurgeCommand.php @@ -0,0 +1,36 @@ +subDays(30); + AuthAttempt::where('updated_at', '<', $cutoff) + ->delete(); + } +} diff --git a/src/app/Http/Controllers/API/V4/AuthAttemptsController.php b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php new file mode 100644 index 00000000..ba885e4a --- /dev/null +++ b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php @@ -0,0 +1,120 @@ +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 index 00000000..70e7ecc5 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/CompanionAppsController.php @@ -0,0 +1,58 @@ +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 index 00000000..82fa08de --- /dev/null +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -0,0 +1,203 @@ + + * 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 index 00000000..9c842771 --- /dev/null +++ b/src/config/firebase.php @@ -0,0 +1,7 @@ + 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 index bdd2b394..c0ee8dc0 100644 --- a/src/config/imap.php +++ b/src/config/imap.php @@ -1,9 +1,12 @@ env('IMAP_URI', '127.0.0.1'), '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 index 00000000..9c0d98b0 --- /dev/null +++ b/src/config/smtp.php @@ -0,0 +1,6 @@ + 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 index 00000000..bb3a12ef --- /dev/null +++ b/src/database/migrations/2021_03_25_144555_create_auth_attempts_table.php @@ -0,0 +1,46 @@ +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 index 00000000..f15d94ae --- /dev/null +++ b/src/database/migrations/2021_05_05_134357_create_companion_apps_table.php @@ -0,0 +1,45 @@ +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 index 8aace6ff..211de2a8 100644 --- a/src/database/seeds/local/UserSeeder.php +++ b/src/database/seeds/local/UserSeeder.php @@ -1,202 +1,205 @@ 'kolab.org', 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $john = User::create( [ 'email' => 'john@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $john->setSettings( [ 'first_name' => 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'organization' => 'Kolab Developers', 'phone' => '+1 509-248-1111', ] ); $john->setAliases(['john.doe@kolab.org']); $wallet = $john->wallets->first(); $packageDomain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $packageLite = \App\Package::withEnvTenantContext()->where('title', 'lite')->first(); $domain->assignPackage($packageDomain, $john); $john->assignPackage($packageKolab); $jack = User::create( [ 'email' => 'jack@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $jack->setSettings( [ 'first_name' => 'Jack', 'last_name' => 'Daniels', 'currency' => 'USD', 'country' => 'US' ] ); $jack->setAliases(['jack.daniels@kolab.org']); $john->assignPackage($packageKolab, $jack); foreach ($john->entitlements as $entitlement) { $entitlement->created_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->updated_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->save(); } $ned = User::create( [ 'email' => 'ned@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $ned->setSettings( [ 'first_name' => 'Edward', 'last_name' => 'Flanders', 'currency' => 'USD', - 'country' => 'US' + 'country' => 'US', + // 'limit_geo' => json_encode(["CH"]), + 'guam_enabled' => false, + '2fa_enabled' => true ] ); $john->assignPackage($packageKolab, $ned); $ned->assignSku(\App\Sku::withEnvTenantContext()->where('title', 'activesync')->first(), 1); // Ned is a controller on Jack's wallet $john->wallets()->first()->addController($ned); // Ned is also our 2FA test user $sku2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $ned->assignSku($sku2fa); try { SecondFactor::seed('ned@kolab.org'); } catch (\Exception $e) { // meh } $joe = User::create( [ 'email' => 'joe@kolab.org', 'password' => \App\Utils::generatePassphrase() ] ); $john->assignPackage($packageLite, $joe); //$john->assignSku(Sku::firstOrCreate(['title' => 'beta'])); //$john->assignSku(Sku::firstOrCreate(['title' => 'meet'])); $joe->setAliases(['joe.monster@kolab.org']); $jeroen = User::create( [ 'email' => 'jeroen@jeroen.jeroen', 'password' => \App\Utils::generatePassphrase() ] ); $jeroen->role = 'admin'; $jeroen->save(); $reseller = User::create( [ 'email' => 'reseller@' . \config('app.domain'), 'password' => \App\Utils::generatePassphrase() ] ); $reseller->role = 'reseller'; $reseller->save(); $reseller->assignPackage($packageKolab); // for tenants that are not the configured tenant id $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); foreach ($tenants as $tenant) { $domain = Domain::where('tenant_id', $tenant->id)->first(); $packageKolab = \App\Package::where( [ 'title' => 'kolab', 'tenant_id' => $tenant->id ] )->first(); if ($domain) { $reseller = User::create( [ 'email' => 'reseller@' . $domain->namespace, 'password' => \App\Utils::generatePassphrase() ] ); $reseller->role = 'reseller'; $reseller->tenant_id = $tenant->id; $reseller->save(); $reseller->assignPackage($packageKolab); $user = User::create( [ 'email' => 'user@' . $domain->namespace, 'password' => \App\Utils::generatePassphrase() ] ); $user->tenant_id = $tenant->id; $user->save(); $user->assignPackage($packageKolab); } } } } diff --git a/src/phpstan.neon b/src/phpstan.neon index 4cb7f813..3c10190b 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,16 +1,15 @@ includes: - ./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\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withSubjectTenantContext\(\)#' - '#Call to an undefined method Tests\\Browser::#' level: 4 parallel: processTimeout: 300.0 paths: - app/ - tests/ diff --git a/src/routes/api.php b/src/routes/api.php index 1d1a5d74..31323f39 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,236 +1,246 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::post('signup/init', 'API\SignupController@init'); Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); + + Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { + Route::post('companion/register', 'API\V4\CompanionAppsController@register'); + + Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm'); + Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny'); + Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details'); + Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index'); + Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig'); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', 'API\V4\UsersController@setConfig'); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); //Route::delete('payments', 'API\V4\PaymentsController@cancel'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), - 'prefix' => $prefix . 'api/webhooks/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'); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend'); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); Route::post('payments', 'API\V4\Reseller\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments'); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); } diff --git a/src/tests/Feature/AuthAttemptTest.php b/src/tests/Feature/AuthAttemptTest.php new file mode 100644 index 00000000..0b096afb --- /dev/null +++ b/src/tests/Feature/AuthAttemptTest.php @@ -0,0 +1,40 @@ +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 index 00000000..3b21021b --- /dev/null +++ b/src/tests/Feature/Console/AuthAttempt/DeleteTest.php @@ -0,0 +1,47 @@ +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 index 00000000..6b7f4c62 --- /dev/null +++ b/src/tests/Feature/Console/AuthAttempt/ListTest.php @@ -0,0 +1,51 @@ +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 index 00000000..8b28ff86 --- /dev/null +++ b/src/tests/Feature/Console/AuthAttempt/PurgeTest.php @@ -0,0 +1,57 @@ +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 index 00000000..ad6cae79 --- /dev/null +++ b/src/tests/Feature/Controller/AuthAttemptsTest.php @@ -0,0 +1,131 @@ +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//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//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//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 index 00000000..08eb45be --- /dev/null +++ b/src/tests/Feature/Controller/CompanionAppsTest.php @@ -0,0 +1,82 @@ +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 index 00000000..1537cd6c --- /dev/null +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -0,0 +1,166 @@ +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'); + } +}