diff --git a/docker/webapp/Dockerfile b/docker/webapp/Dockerfile index 7f142b82..cfbd755a 100755 --- a/docker/webapp/Dockerfile +++ b/docker/webapp/Dockerfile @@ -1,17 +1,17 @@ FROM apheleia/swoole:latest MAINTAINER Jeroen van Meeuwen <vanmeeuwen@apheleia-it.ch> USER root -RUN dnf -y install findutils gnupg2 git rsync procps-ng +RUN dnf -y install findutils gnupg2 git rsync procps-ng php-sodium EXPOSE 8000 ARG GIT_REF=master COPY build.sh /build.sh RUN /build.sh COPY init.sh /init.sh COPY update.sh /update.sh CMD [ "/init.sh" ] diff --git a/src/app/Http/Controllers/API/V4/VPNController.php b/src/app/Http/Controllers/API/V4/VPNController.php new file mode 100644 index 00000000..f8ee8d2e --- /dev/null +++ b/src/app/Http/Controllers/API/V4/VPNController.php @@ -0,0 +1,40 @@ +<?php + +namespace App\Http\Controllers\API\V4; + +use App\Http\Controllers\Controller; +use Carbon\Carbon; +use Illuminate\Http\Request; +use Lcobucci\JWT\Encoding\ChainedFormatter; +use Lcobucci\JWT\Encoding\JoseEncoder; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Signer\Rsa; +use Lcobucci\JWT\Token\Builder; + +class VPNController extends Controller +{ + /** + * Token request from the vpn module + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function token(Request $request) + { + $signingKey = \config("app.vpn.token_signing_key"); + if (empty($signingKey)) { + throw new \Exception("app.vpn.token_signing_key is not set"); + } + + $tokenBuilder = (new Builder(new JoseEncoder(), ChainedFormatter::default())); + $token = $tokenBuilder + ->issuedAt(Carbon::now()->toImmutable()) + // The entitlement is hardcoded for now to default. + // Can be extended in the future based on user entitlements. + ->withClaim('entitlement', "default") + ->getToken(new Rsa\Sha256(), InMemory::plainText($signingKey)); + + return response()->json(['status' => 'ok', 'token' => $token->toString()]); + } +} diff --git a/src/composer.json b/src/composer.json index 47448f29..2bef1fe2 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,86 +1,87 @@ { "name": "kolab/kolab4", "type": "project", "description": "Kolab 4", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^8.1", "bacon/bacon-qr-code": "^2.0", "barryvdh/laravel-dompdf": "^2.0.0", "doctrine/dbal": "^3.3.2", "dyrynda/laravel-nullable-fields": "^4.2.0", "guzzlehttp/guzzle": "^7.4.1", "kolab/net_ldap3": "dev-master", "laravel/framework": "^9.2", "laravel/horizon": "^5.9", "laravel/octane": "^1.2", "laravel/passport": "^11.3", "laravel/tinker": "^2.7", "league/flysystem-aws-s3-v3": "^3.0", "mlocati/spf-lib": "^3.1", "mollie/laravel-mollie": "^2.19", "pear/crypt_gpg": "^1.6.6", "predis/predis": "^2.0", "sabre/vobject": "^4.5", "spatie/laravel-translatable": "^6.3", "spomky-labs/otphp": "~10.0.0", - "stripe/stripe-php": "^10.7" + "stripe/stripe-php": "^10.7", + "lcobucci/jwt": "^5.0" }, "require-dev": { "code-lts/doctum": "^5.5.1", "laravel/dusk": "~7.5.0", "mockery/mockery": "^1.5", "nunomaduro/larastan": "^2.0", "phpstan/phpstan": "^1.4", "phpunit/phpunit": "^9", "squizlabs/php_codesniffer": "^3.6" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "stable", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/config/app.php b/src/config/app.php index 7ae1aad5..888a3a53 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,284 +1,288 @@ <?php return [ /* |-------------------------------------------------------------------------- | Application Name |-------------------------------------------------------------------------- | | This value is the name of your application. This value is used when the | framework needs to place the application's name in a notification or | any other location as required by the application or its packages. | */ 'name' => env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. */ 'url' => env('APP_URL', 'http://localhost'), 'passphrase' => env('APP_PASSPHRASE', null), 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), 'asset_url' => env('ASSET_URL'), 'support_url' => env('SUPPORT_URL', null), 'support_email' => env('SUPPORT_EMAIL', null), 'webmail_url' => env('WEBMAIL_URL', null), 'theme' => env('APP_THEME', 'default'), 'tenant_id' => env('APP_TENANT_ID', null), 'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')), /* |-------------------------------------------------------------------------- | Application Domain |-------------------------------------------------------------------------- | | System domain used for user signup (kolab identity) */ 'domain' => env('APP_DOMAIN', 'domain.tld'), 'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')), 'services_domain' => env( 'APP_SERVICES_DOMAIN', "services." . env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')) ), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => env('APP_LOCALE', 'en'), /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Notifications\NotificationServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\PassportServiceProvider::class, App\Providers\RouteServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => \Illuminate\Support\Facades\Facade::defaultAliases()->toArray(), 'headers' => [ 'csp' => env('APP_HEADER_CSP', ""), 'xfo' => env('APP_HEADER_XFO', ""), ], // Locations of knowledge base articles 'kb' => [ // An article about suspended accounts 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), // An article about the payment system 'payment_system' => env('KB_PAYMENT_SYSTEM'), ], 'company' => [ 'name' => env('COMPANY_NAME'), 'address' => env('COMPANY_ADDRESS'), 'details' => env('COMPANY_DETAILS'), 'email' => env('COMPANY_EMAIL'), 'logo' => env('COMPANY_LOGO'), 'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')), 'copyright' => 'Apheleia IT AG', ], 'storage' => [ 'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB ], 'vat' => [ 'mode' => (int) env('VAT_MODE', 0), ], 'password_policy' => env('PASSWORD_POLICY') ?: 'min:6,max:255', 'payment' => [ 'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', 'creditcard,paypal,banktransfer,bitcoin'), 'methods_recurring' => env('PAYMENT_METHODS_RECURRING', 'creditcard'), ], 'with_ldap' => (bool) env('APP_LDAP', true), 'with_imap' => (bool) env('APP_IMAP', false), 'with_admin' => (bool) env('APP_WITH_ADMIN', false), 'with_files' => (bool) env('APP_WITH_FILES', false), 'with_reseller' => (bool) env('APP_WITH_RESELLER', false), 'with_services' => (bool) env('APP_WITH_SERVICES', false), 'with_signup' => (bool) env('APP_WITH_SIGNUP', true), 'with_subscriptions' => (bool) env('APP_WITH_SUBSCRIPTIONS', true), 'with_wallet' => (bool) env('APP_WITH_WALLET', true), 'signup' => [ 'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0), 'ip_limit' => (int) env('SIGNUP_LIMIT_IP', 0), ], 'woat_ns1' => env('WOAT_NS1', 'ns01.' . env('APP_DOMAIN')), 'woat_ns2' => env('WOAT_NS2', 'ns02.' . env('APP_DOMAIN')), 'ratelimit_whitelist' => explode(',', env('RATELIMIT_WHITELIST', '')), 'companion_download_link' => env( 'COMPANION_DOWNLOAD_LINK', "https://mirror.apheleia-it.ch/pub/companion-app-beta.apk" - ) + ), + + 'vpn' => [ + 'signing_key' => env('VPN_TOKEN_SIGNING_KEY', 0), + ], ]; diff --git a/src/routes/api.php b/src/routes/api.php index 1d581198..6aeb6cb5 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,315 +1,317 @@ <?php use App\Http\Controllers\API; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; /* |-------------------------------------------------------------------------- | API Routes |-------------------------------------------------------------------------- | | Here is where you can register API routes for your application. These | routes are loaded by the RouteServiceProvider within a group which | is assigned the "api" middleware group. Enjoy building your API! | */ Route::group( [ 'middleware' => 'api', 'prefix' => 'auth' ], function () { Route::post('login', [API\AuthController::class, 'login']); Route::group( ['middleware' => 'auth:api'], function () { Route::get('info', [API\AuthController::class, 'info']); Route::post('info', [API\AuthController::class, 'info']); Route::get('location', [API\AuthController::class, 'location']); Route::post('logout', [API\AuthController::class, 'logout']); Route::post('refresh', [API\AuthController::class, 'refresh']); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function () { Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']); Route::post('password-reset/init', [API\PasswordResetController::class, 'init']); Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']); Route::post('password-reset', [API\PasswordResetController::class, 'reset']); } ); if (\config('app.with_signup')) { Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function () { Route::get('signup/domains', [API\SignupController::class, 'domains']); Route::post('signup/init', [API\SignupController::class, 'init']); Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']); Route::get('signup/plans', [API\SignupController::class, 'plans']); Route::post('signup/validate', [API\SignupController::class, 'signupValidate']); Route::post('signup/verify', [API\SignupController::class, 'verify']); Route::post('signup', [API\SignupController::class, 'signup']); } ); } Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => ['auth:api', 'scope:mfa,api'], 'prefix' => 'v4' ], function () { Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']); Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']); Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']); Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']); Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']); } ); if (\config('app.with_files')) { Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => ['auth:api', 'scope:fs,api'], 'prefix' => 'v4' ], function () { Route::apiResource('fs', API\V4\FsController::class); Route::get('fs/{itemId}/permissions', [API\V4\FsController::class, 'getPermissions']); Route::post('fs/{itemId}/permissions', [API\V4\FsController::class, 'createPermission']); Route::put('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'updatePermission']); Route::delete('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'deletePermission']); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => [], 'prefix' => 'v4' ], function () { Route::post('fs/uploads/{id}', [API\V4\FsController::class, 'upload']) ->middleware(['api']); Route::get('fs/downloads/{id}', [API\V4\FsController::class, 'download']); } ); } Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => ['auth:api', 'scope:api'], 'prefix' => 'v4' ], function () { Route::apiResource('companions', API\V4\CompanionAppsController::class); // This must not be accessible with the 2fa token, // to prevent an attacker from pairing a new device with a stolen token. Route::get('companions/{id}/pairing', [API\V4\CompanionAppsController::class, 'pairing']); Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']); Route::get('domains/{id}/skus', [API\V4\DomainsController::class, 'skus']); Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']); Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/skus', [API\V4\GroupsController::class, 'skus']); Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']); Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('rooms', API\V4\RoomsController::class); Route::post('rooms/{id}/config', [API\V4\RoomsController::class, 'setConfig']); Route::get('rooms/{id}/skus', [API\V4\RoomsController::class, 'skus']); Route::post('meet/rooms/{id}', [API\V4\MeetController::class, 'joinRoom']) ->withoutMiddleware(['auth:api', 'scope:api']); Route::apiResource('resources', API\V4\ResourcesController::class); Route::get('resources/{id}/skus', [API\V4\ResourcesController::class, 'skus']); Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']); Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']); Route::apiResource('shared-folders', API\V4\SharedFoldersController::class); Route::get('shared-folders/{id}/skus', [API\V4\SharedFoldersController::class, 'skus']); Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']); Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']); Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']); Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']); Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']); Route::get('password-policy', [API\PasswordPolicyController::class, 'index']); Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']); Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']); Route::post('payments', [API\V4\PaymentsController::class, 'store']); //Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']); Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']); Route::post('payments/mandate/reset', [API\V4\PaymentsController::class, 'mandateReset']); Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']); Route::get('payments/status', [API\V4\PaymentsController::class, 'paymentStatus']); Route::post('support/request', [API\V4\SupportController::class, 'request']) ->withoutMiddleware(['auth:api', 'scope:api']) ->middleware(['api']); + + Route::get('vpn/token', [API\V4\VPNController::class, 'token']); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => 'webhooks' ], function () { Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']); Route::post('meet', [API\V4\MeetController::class, 'webhook']); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => \config('app.services_domain'), 'prefix' => 'webhooks' ], function () { Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']); Route::get('nginx-roundcube', [API\V4\NGINXController::class, 'authenticateRoundcube']); Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']); Route::post('cyrus-sasl', [API\V4\NGINXController::class, 'cyrussasl']); Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']); Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']); Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Admin\DomainsController::class, 'skus']); Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']); Route::apiResource('resources', API\V4\Admin\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class); 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::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']); Route::post('users/{id}/resetGeoLock', [API\V4\Admin\UsersController::class, 'resetGeoLock']); Route::post('users/{id}/resync', [API\V4\Admin\UsersController::class, 'resync']); Route::get('users/{id}/skus', [API\V4\Admin\UsersController::class, 'skus']); Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Reseller\DomainsController::class, 'skus']); Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']); Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']); Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']); Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']); Route::apiResource('resources', API\V4\Reseller\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class); 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::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']); Route::post('users/{id}/resetGeoLock', [API\V4\Reseller\UsersController::class, 'resetGeoLock']); Route::post('users/{id}/resync', [API\V4\Reseller\UsersController::class, 'resync']); Route::get('users/{id}/skus', [API\V4\Reseller\UsersController::class, 'skus']); Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']); Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']); } ); } diff --git a/src/tests/Feature/Controller/VPNTest.php b/src/tests/Feature/Controller/VPNTest.php new file mode 100644 index 00000000..10b67a7c --- /dev/null +++ b/src/tests/Feature/Controller/VPNTest.php @@ -0,0 +1,130 @@ +<?php + +namespace Tests\Feature\Controller; + +use Tests\TestCase; +use Carbon\Carbon; +use Lcobucci\JWT\Encoding\JoseEncoder; +use Lcobucci\JWT\Token\Parser; +use Lcobucci\JWT\Token\RegisteredClaims; +use Lcobucci\JWT\Signer\Rsa\Sha256; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Validation\Constraint; +use Lcobucci\JWT\Validation\Validator; +use Lcobucci\JWT\Validation\RequiredConstraintsViolated; + +class VPNTest extends TestCase +{ + /** + * {@inheritDoc} + */ + public function setUp(): void + { + Carbon::setTestNow(Carbon::create(2022, 02, 02, 13, 0, 0)); + parent::setUp(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + parent::tearDown(); + } + + /** + * Test the webhook + */ + public function testToken(): void + { + // openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048 + $privateKey = <<<EOF +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmfUb5J+na6LV1 +6PTmOX6alvyEbWEUA1HykKFsKRq3q8nraZU4LlfhM1qoeA23re4WEmT2SoCXq8sd +ewRXP+swYl6vAPZFtqCR52vApEXn+b221g3gEa5KnZeQ3APTej6wcs+3etMcDFAh +Sabrjvl3eoHXmO2RQoeSjSfp6N4LPvbbOjkLR5EYdDNxVuwU7HRkwkXve1rWh94z +RbSl7o4XkP/qzOGEDfrm7SivcSWqwFndHkxNeum1FnBfDZ9ChOwEunuIZiPX2TDb +K3WZ+TJn29xNERAJJgFZLspB9RK+FT/lCX4IZgdl8ZyU8lC68QXaZuAcQhg7TU7l +xBw7ZJXBAgMBAAECggEAAqEL/R9tJmISNYJgKv+MQ9mFCNFSXQwgCpN6nRg5aLOb +h25o0b+M7zc4pAbrTokpBJZgm5wPE6C99HakGahJKOqGF2o4OV8aLNupnFvrvQSj +reQLnuF17g3hiMMqA02qTkPYiwud6FLlyV9zSu58etoc0UsEBg0gfMcNCDj/bVAt +b+JvZjfFXG9TQRNx7F3H/yRO5kHw+oqeDBAvHN+Mkq9KATLQ8GBZznwpN7fHL+Is +PZCDvcAg/4OtN1cNgMOKkdBDSMzjtn7xHUX9jl03VUPvXfa9KDdFCMdVndtnACRK +lFNMdD+ugQ7Ch1oHI16HUWo663fjF+9m3neYjoAiwQKBgQC+UykotVMHX65OsYx7 +tXankRmj9WnoiUFrfClrdVynbkcbZvCt9NdNluv0eNRbEaNXD07nOIppACSDaQ0i +DdIALbFU6I3kzAF+z25Rh6SaIl5gaUGxSLSzmpswJsv0hiu/5Sb8t6fOX+VWBV7I +Fw7MqASoggV5rEAvRVtwiw4TsQKBgQDf8IwZDwXlTFc7/VsAjLfb+edpVcavOB9d +DAXAwUH+2FJb9AWPOLrTnKdsRV9yw9FueMciuROGi9btOYxEcLGvNcvsyaWy0eFQ +vN2w0NsxpBodRylxMQLGDc5wO9lbrVuftzC0rDaQaD9gPQaKFoQI7ww81Jmx6u1Y +OP/Nsq53EQKBgD8vIoHmOItBI3/yh53mL18P18BLz/4n2vURAjsvejQHc0nQkeRe +XT/f87N0jaMyJtTXOy2d4q1bI8QQkxCUH/x5Lt7uWXT0mSZ9PLWKX4XgFQ7SwsFV +TtA1aoHAz4L9K/cH3zqUyfvEcEFvhPjOVtZwjSNYDvNG0QQgdWvWbjTxAoGAE50R +6C/0qDyjd1GdYtLwV4fvyL4GhNo5hQDEkDlc+mEf9YXN5tllI5uY3lbFIVwdP7u8 +VUI4f5RH4scjjesA5QOlNLwEk0DmpxejoxTn3dUtpFrTOmK8h3Q2HIZhZzIr0DVP +QsPCk6tNwbQWmomWTuIBBGLqgza8SvnTDcUUmsECgYBXk4MAk1FennJuDvpiD1Gg +HiIQykUOQeA5wk+R97X7D4kI8kj6XzCuRjG+nSJiYmpZBHRvA5XtRtMJWep11R6O +8yu8ftj4xBQr6roUoHwJ/JBxe8JuKW3yh52CaZLP2KjizwzNI0hDMUzinZIReex+ +iFyt8WwtMwzW/520PlwUyQ== +-----END PRIVATE KEY----- +EOF; + + // openssl rsa -pubout -in private.pem -out public.pem + $publicKey = <<<EOF +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApn1G+Sfp2ui1dej05jl+ +mpb8hG1hFANR8pChbCkat6vJ62mVOC5X4TNaqHgNt63uFhJk9kqAl6vLHXsEVz/r +MGJerwD2RbagkedrwKRF5/m9ttYN4BGuSp2XkNwD03o+sHLPt3rTHAxQIUmm6475 +d3qB15jtkUKHko0n6ejeCz722zo5C0eRGHQzcVbsFOx0ZMJF73ta1ofeM0W0pe6O +F5D/6szhhA365u0or3ElqsBZ3R5MTXrptRZwXw2fQoTsBLp7iGYj19kw2yt1mfky +Z9vcTREQCSYBWS7KQfUSvhU/5Ql+CGYHZfGclPJQuvEF2mbgHEIYO01O5cQcO2SV +wQIDAQAB +-----END PUBLIC KEY----- +EOF; + + \config(['app.vpn.token_signing_key' => $privateKey]); + + $john = $this->getTestUser('john@kolab.org'); + + $response = $this->get("api/v4/vpn/token"); + $response->assertStatus(401); + + $response = $this->actingAs($john)->get("api/v4/vpn/token"); + $response->assertStatus(200); + + $json = $response->json(); + $jwt = $json['token']; + + $parser = new Parser(new JoseEncoder()); + + /** @var \Lcobucci\JWT\UnencryptedToken $token */ + $token = $parser->parse($jwt); + + $this->assertSame("default", $token->claims()->get('entitlement')); + $this->assertSame("2022-02-02T13:00:00+00:00", $token->claims()->get( + RegisteredClaims::ISSUED_AT + )->format(\DateTimeImmutable::RFC3339)); + $this->assertSame(0, Carbon::now()->diffInSeconds(new Carbon($token->claims()->get( + RegisteredClaims::ISSUED_AT + )))); + + $validator = new Validator(); + $key = InMemory::plainText($publicKey); + $validator->assert($token, new Constraint\SignedWith(new Sha256(), $key)); + + $invalidKey = <<<EOF +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApn1G+Sfp2ui1dej05jl+ +mpb8hG1hFANR8pChbCkat6vJ62mVOC5X4TNaqHgNt63uFhJk9kqAl6vLHXsEVz/r +MGJerwD2RbagkedrwKRF5/m9ttYN4BGuSp2XkNwD03o+sHLPt3rTHAxQIUmm6475 +d3qB15jtkUKHko0n6ejeCz722zo5C0eRGHQzcVbsFOx0ZMJF73ta1ofeM0W0pe6O +F5D/6szhhA365u0or3ElqsBZ3R5MTXrptRZwXw2fQoTsBLp7iGYj19kw2yt1mfky +Z9vcTREQCSYBWS7KQfUSvhU/5Ql+CGYHZfGclPJQuvEF2mbgHEIYO01O5cQcO2SV +wQIDAQAC +-----END PUBLIC KEY----- +EOF; + $this->expectException(RequiredConstraintsViolated::class); + $key = InMemory::plainText($invalidKey); + $validator->assert($token, new Constraint\SignedWith(new Sha256(), $key)); + } +}