diff --git a/app/Foundation/Providers/RouteServiceProvider.php b/app/Foundation/Providers/RouteServiceProvider.php index cca41e32..9a17c2a6 100644 --- a/app/Foundation/Providers/RouteServiceProvider.php +++ b/app/Foundation/Providers/RouteServiceProvider.php @@ -13,7 +13,13 @@ namespace CachetHQ\Cachet\Foundation\Providers; use Barryvdh\Cors\HandleCors; use CachetHQ\Cachet\Http\Middleware\Acceptable; +use CachetHQ\Cachet\Http\Middleware\Authenticate; use CachetHQ\Cachet\Http\Middleware\Timezone; +use CachetHQ\Cachet\Http\Routes\ApiSystemRoutes; +use CachetHQ\Cachet\Http\Routes\AuthRoutes; +use CachetHQ\Cachet\Http\Routes\Setup\ApiRoutes as ApiSetupRoutes; +use CachetHQ\Cachet\Http\Routes\SetupRoutes; +use CachetHQ\Cachet\Http\Routes\SignupRoutes; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; @@ -41,6 +47,21 @@ class RouteServiceProvider extends ServiceProvider */ protected $namespace = 'CachetHQ\Cachet\Http\Controllers'; + /** + * These are the route files that should always be available anonymously. + * + * When applying the always_authenticate feature, these routes will be skipped. + * + * @var string[] + */ + protected $whitelistedAuthRoutes = [ + AuthRoutes::class, + SetupRoutes::class, + SignupRoutes::class, + ApiSystemRoutes::class, + ApiSetupRoutes::class, + ]; + /** * Define the route model bindings, pattern filters, etc. * @@ -89,6 +110,7 @@ class RouteServiceProvider extends ServiceProvider $router->group(['namespace' => $this->namespace, 'as' => 'core::'], function (Router $router) { $path = app_path('Http/Routes'); + $applyAlwaysAuthenticate = $this->app['config']->get('setting.always_authenticate', false); $AllFileIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path)); $PhpFileIterator = new \RegexIterator($AllFileIterator, '/^.+\.php$/i', \RecursiveRegexIterator::GET_MATCH); @@ -100,9 +122,9 @@ class RouteServiceProvider extends ServiceProvider $routes = $this->app->make("CachetHQ\\Cachet\\Http\\Routes${class}"); if ($routes::$browser) { - $this->mapForBrowser($router, $routes); + $this->mapForBrowser($router, $routes, $applyAlwaysAuthenticate); } else { - $this->mapOtherwise($router, $routes); + $this->mapOtherwise($router, $routes, $applyAlwaysAuthenticate); } } }); @@ -113,10 +135,11 @@ class RouteServiceProvider extends ServiceProvider * * @param \Illuminate\Routing\Router $router * @param object $routes + * @param bool $applyAlwaysAuthenticate * * @return void */ - protected function mapForBrowser(Router $router, $routes) + protected function mapForBrowser(Router $router, $routes, $applyAlwaysAuthenticate) { $middleware = [ EncryptCookies::class, @@ -127,6 +150,10 @@ class RouteServiceProvider extends ServiceProvider SubstituteBindings::class, ]; + if ($applyAlwaysAuthenticate && !$this->isWhiteListedAuthRoute($routes)) { + $middleware[] = Authenticate::class; + } + $router->group(['middleware' => $middleware], function (Router $router) use ($routes) { $routes->map($router); }); @@ -137,10 +164,11 @@ class RouteServiceProvider extends ServiceProvider * * @param \Illuminate\Routing\Router $router * @param object $routes + * @param bool $applyAlwaysAuthenticate * * @return void */ - protected function mapOtherwise(Router $router, $routes) + protected function mapOtherwise(Router $router, $routes, $applyAlwaysAuthenticate) { $middleware = [ HandleCors::class, @@ -149,8 +177,31 @@ class RouteServiceProvider extends ServiceProvider Timezone::class, ]; + if ($applyAlwaysAuthenticate && !$this->isWhiteListedAuthRoute($routes)) { + $middleware[] = 'auth.api:true'; + } + $router->group(['middleware' => $middleware], function (Router $router) use ($routes) { $routes->map($router); }); } + + /** + * Validates if the route object is an instance of the whitelisted routes. + * A small workaround since we cant use multiple classes in a `instanceof` comparison. + * + * @param object $routes + * + * @return bool + */ + private function isWhiteListedAuthRoute($routes) + { + foreach ($this->whitelistedAuthRoutes as $whitelistedRoute) { + if (is_a($routes, $whitelistedRoute)) { + return true; + } + } + + return false; + } } diff --git a/app/Http/Controllers/Dashboard/SettingsController.php b/app/Http/Controllers/Dashboard/SettingsController.php index 5417f59d..65829c93 100644 --- a/app/Http/Controllers/Dashboard/SettingsController.php +++ b/app/Http/Controllers/Dashboard/SettingsController.php @@ -20,6 +20,7 @@ use Exception; use GrahamCampbell\Binput\Facades\Binput; use Illuminate\Log\Writer; use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Lang; @@ -384,6 +385,10 @@ class SettingsController extends Controller Lang::setLocale(Binput::get('app_locale')); } + if (Binput::has('always_authenticate')) { + Artisan::call('route:clear'); + } + return Redirect::back()->withSuccess(trans('dashboard.settings.edit.success')); } diff --git a/app/Http/Routes/ApiRoutes.php b/app/Http/Routes/ApiRoutes.php index 3408123a..d18e4064 100644 --- a/app/Http/Routes/ApiRoutes.php +++ b/app/Http/Routes/ApiRoutes.php @@ -41,10 +41,6 @@ class ApiRoutes 'prefix' => 'api/v1', ], function (Registrar $router) { $router->group(['middleware' => ['auth.api']], function (Registrar $router) { - $router->get('ping', 'GeneralController@ping'); - $router->get('version', 'GeneralController@version'); - $router->get('status', 'GeneralController@status'); - $router->get('components', 'ComponentController@index'); $router->get('components/groups', 'ComponentGroupController@index'); $router->get('components/groups/{component_group}', 'ComponentGroupController@show'); diff --git a/app/Http/Routes/ApiSystemRoutes.php b/app/Http/Routes/ApiSystemRoutes.php new file mode 100644 index 00000000..80899f71 --- /dev/null +++ b/app/Http/Routes/ApiSystemRoutes.php @@ -0,0 +1,50 @@ + + */ +class ApiSystemRoutes +{ + /** + * Defines if these routes are for the browser. + * + * @var bool + */ + public static $browser = false; + + /** + * Define the api routes for the system status, ping and version. + * + * @param \Illuminate\Contracts\Routing\Registrar $router + * + * @return void + */ + public function map(Registrar $router) + { + $router->group([ + 'namespace' => 'Api', + 'prefix' => 'api/v1', + ], function (Registrar $router) { + $router->group(['middleware' => ['auth.api']], function (Registrar $router) { + $router->get('ping', 'GeneralController@ping'); + $router->get('version', 'GeneralController@version'); + $router->get('status', 'GeneralController@status'); + }); + }); + } +} diff --git a/config/setting.php b/config/setting.php index 4f58d404..c9615c35 100644 --- a/config/setting.php +++ b/config/setting.php @@ -111,4 +111,16 @@ return [ */ 'only_disrupted_days' => false, + + /* + |-------------------------------------------------------------------------- + | Always authenticate + |-------------------------------------------------------------------------- + | + | Whether to lock down Cachet and only allow viewing pages + | when authenticated. + | + */ + + 'always_authenticate' => false, ]; diff --git a/resources/lang/en/forms.php b/resources/lang/en/forms.php index 6425fa34..f5a11877 100644 --- a/resources/lang/en/forms.php +++ b/resources/lang/en/forms.php @@ -177,8 +177,10 @@ return [ 'incident-date-format' => 'Incident timestamp format', ], 'security' => [ - 'allowed-domains' => 'Allowed domains', - 'allowed-domains-help' => 'Comma separated. The domain set above is automatically allowed by default.', + 'allowed-domains' => 'Allowed domains', + 'allowed-domains-help' => 'Comma separated. The domain set above is automatically allowed by default.', + 'always-authenticate' => 'Always authenticate', + 'always-authenticate-help' => 'Require login to view any Cachet page', ], 'stylesheet' => [ 'custom-css' => 'Custom Stylesheet', diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 7c17920f..a5f709bf 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -36,11 +36,13 @@
+ @if(!config('setting.always_authenticate', false)) + @endif
diff --git a/resources/views/dashboard/settings/security.blade.php b/resources/views/dashboard/settings/security.blade.php index 7d832b2b..c0cb81c1 100644 --- a/resources/views/dashboard/settings/security.blade.php +++ b/resources/views/dashboard/settings/security.blade.php @@ -15,6 +15,18 @@ @include('dashboard.partials.errors')
+
+
+ +
+ +
+
+
diff --git a/tests/Foundation/Providers/RouteServiceProviderTest.php b/tests/Foundation/Providers/RouteServiceProviderTest.php index 911c6f08..f88f03f8 100644 --- a/tests/Foundation/Providers/RouteServiceProviderTest.php +++ b/tests/Foundation/Providers/RouteServiceProviderTest.php @@ -12,7 +12,12 @@ namespace CachetHQ\Tests\Cachet\Foundation\Providers; use AltThree\TestBench\ServiceProviderTrait; +use CachetHQ\Cachet\Foundation\Providers\RouteServiceProvider; +use CachetHQ\Cachet\Http\Middleware\Authenticate; use CachetHQ\Tests\Cachet\AbstractTestCase; +use Illuminate\Routing\Route; +use Illuminate\Routing\RouteCollection; +use Illuminate\Routing\Router; /** * This is the route service provider test class. @@ -22,4 +27,195 @@ use CachetHQ\Tests\Cachet\AbstractTestCase; class RouteServiceProviderTest extends AbstractTestCase { use ServiceProviderTrait; + + /** + * The login routes should always be available regardless of the always authenticate setting. + */ + public function testWhenAlwaysAuthenticateIsEnabledLoginRoutesAreWhiteListed() + { + $loginRoutes = [ + 'core::get:auth.login', + 'core::post:auth.login', + 'core::post:auth.two-factor', + 'core::get:auth.logout', + 'core::get:signup.invite', + 'core::post:signup.invite', + ]; + + $this->assertRoutesDontHaveAuthMiddleware($loginRoutes, $this->bootRouter(true)); + } + + /** + * The setup routes should always be available regardless of the always authenticate setting. + */ + public function testWhenAlwaysAuthenticateIsEnabledSetupRoutesAreWhiteListed() + { + $loginRoutes = [ + 'core::get:setup', + 'core::post:setup.step1', + 'core::post:setup.step2', + 'core::post:setup.step3', + ]; + + $this->assertRoutesDontHaveAuthMiddleware($loginRoutes, $this->bootRouter(true)); + } + + /** + * It's possible to retrieve the cachet version, status and ping endpoints regardless of the + * always authenticate setting. + */ + public function testWhenAlwaysAuthenticateIsEnabledApiSystemRoutesAreWhiteListed() + { + $routeActions = [ + 'CachetHQ\Cachet\Http\Controllers\Api\GeneralController@ping', + 'CachetHQ\Cachet\Http\Controllers\Api\GeneralController@version', + 'CachetHQ\Cachet\Http\Controllers\Api\GeneralController@status', + ]; + + $router = $this->bootRouter(true); + + foreach ($routeActions as $routeAction) { + $route = $router->getRoutes()->getByAction($routeAction); + $this->assertInstanceOf(Route::class, $route); + + $middleware = $route->gatherMiddleware(); + $this->assertFalse(in_array('auth.api:true', $middleware, true)); + } + } + + /** + * When using always authenticate, normal graceful api routes will require full authentication. + */ + public function testWhenAlwaysAuthenticateIsEnabledApiRoutesAreHardAuthenticated() + { + $routeActions = [ + 'CachetHQ\Cachet\Http\Controllers\Api\ComponentController@index', + 'CachetHQ\Cachet\Http\Controllers\Api\ComponentGroupController@index', + 'CachetHQ\Cachet\Http\Controllers\Api\ComponentGroupController@show', + 'CachetHQ\Cachet\Http\Controllers\Api\ComponentController@show', + 'CachetHQ\Cachet\Http\Controllers\Api\IncidentController@index', + 'CachetHQ\Cachet\Http\Controllers\Api\IncidentController@show', + 'CachetHQ\Cachet\Http\Controllers\Api\IncidentUpdateController@index', + 'CachetHQ\Cachet\Http\Controllers\Api\IncidentUpdateController@show', + 'CachetHQ\Cachet\Http\Controllers\Api\MetricController@index', + 'CachetHQ\Cachet\Http\Controllers\Api\MetricController@show', + 'CachetHQ\Cachet\Http\Controllers\Api\MetricPointController@index', + 'CachetHQ\Cachet\Http\Controllers\Api\ScheduleController@index', + 'CachetHQ\Cachet\Http\Controllers\Api\ScheduleController@show', + ]; + + $router = $this->bootRouter(true); + + foreach ($routeActions as $routeAction) { + $route = $router->getRoutes()->getByAction($routeAction); + $this->assertInstanceOf(Route::class, $route); + + $middleware = $route->gatherMiddleware(); + $this->assertTrue(in_array('auth.api:true', $middleware, true)); + } + } + + /** + * When enabling the always authenticate setting, the core frontpage routes require authentication. + */ + public function testWhenAlwaysAuthenticateIsEnabledAllNormalRoutesAreAuthenticated() + { + $namedRoutes = [ + 'core::get:status-page', + 'core::get:incident', + 'core::get:schedule', + 'core::get:metric', + 'core::get:component_shield', + 'core::get:feed.atom', + 'core::get:feed.rss', + 'core::get:subscribe', + 'core::post:subscribe', + 'core::get:subscribe.manage', + 'core::post:subscribe.manage', + 'core::get:subscribe.verify', + 'core::get:subscribe.unsubscribe', + ]; + + $this->assertRoutesHaveAuthMiddleware($namedRoutes, $this->bootRouter(true)); + } + + /** + * This test asserts that when always authenticate is disabled, you are allowed to visit the frontpage + * routes without enforced authentication. + */ + public function testWhenAlwaysAuthenticateIsDisabledAllNormalRoutesAreUnauthenticated() + { + $namedRoutes = [ + 'core::get:status-page', + 'core::get:incident', + 'core::get:schedule', + 'core::get:metric', + 'core::get:component_shield', + 'core::get:feed.atom', + 'core::get:feed.rss', + 'core::get:subscribe', + 'core::post:subscribe', + 'core::get:subscribe.manage', + 'core::post:subscribe.manage', + 'core::get:subscribe.verify', + 'core::get:subscribe.unsubscribe', + ]; + + $this->assertRoutesDontHaveAuthMiddleware($namedRoutes, $this->bootRouter(false)); + } + + /** + * A helper method that will execute the RouteProvider's map function and return a clean router. + * + * @param bool $alwaysAuthenticate + * + * @return Router + */ + private function bootRouter($alwaysAuthenticate) + { + $this->app->config->set('setting.always_authenticate', $alwaysAuthenticate); + $router = $this->app->make(Router::class); + $router->setRoutes(new RouteCollection()); + + $routeServiceProvider = new RouteServiceProvider($this->app); + $routeServiceProvider->map($router); + + return $router; + } + + /** + * Assertion helper that asserts if the authentication middleware has not been injected onto + * the collection of named routes. + * + * @param array $routeNames + * @param Router $router + */ + private function assertRoutesDontHaveAuthMiddleware(array $routeNames, Router $router) + { + foreach ($routeNames as $routeName) { + $route = $router->getRoutes()->getByName($routeName); + $this->assertInstanceOf(Route::class, $route); + + $middleware = $route->gatherMiddleware(); + $this->assertFalse(in_array(Authenticate::class, $middleware, true)); + } + } + + /** + * Assertion helper that asserts if the authentication middleware has been injected onto + * the collection of named routes. + * + * @param array $routeNames + * @param Router $router + */ + private function assertRoutesHaveAuthMiddleware(array $routeNames, Router $router) + { + foreach ($routeNames as $routeName) { + $route = $router->getRoutes()->getByName($routeName); + $this->assertInstanceOf(Route::class, $route); + + $middleware = $route->gatherMiddleware(); + $this->assertTrue(in_array(Authenticate::class, $middleware, true)); + } + } }