diff --git a/.env.example b/.env.example index 72c2cc41..63006bea 100644 --- a/.env.example +++ b/.env.example @@ -21,9 +21,12 @@ SITE_OWNER=mail@example.com # The encryption key for your database and sessions. Keep this very secure. # If you generate a new one all existing data must be considered LOST. -# Change it to a string of exactly 32 chars or use command `php artisan key:generate` to generate it +# +# You can leave this empty if you use `php artisan 2fauth:install`. +# Otherwise, change it to a string of exactly 32 chars or use command +# `php artisan key:generate` to generate it. -APP_KEY=SomeRandomStringOf32CharsExactly +APP_KEY= # This variable must match your installation's external address but keep in mind that @@ -74,17 +77,41 @@ FILESYSTEM_DRIVER=local #### Database config & credentials #### -DB_CONNECTION=sqlite -DB_DATABASE="path/to/your/database.sqlite" - -# or if you want to use SQL (uncomment lines) +# Supported values for DB_CONNECTION: mysql|pgsql|sqlsrv|sqlite +# mysql => MySQL +# pgsql => PostGreSQL +# sqlsrv => SQL server +# sqlite => SQLite +# Example for a MySQL database connection +# # DB_CONNECTION=mysql +# DB_DATABASE=my_2fauth_DB_name # DB_HOST=127.0.0.1 # DB_PORT=3306 -# DB_DATABASE=homestead -# DB_USERNAME=homestead -# DB_PASSWORD=secret +# DB_USERNAME=my_2fauth_db_user +# DB_PASSWORD=My_d8_S3cr3t + +# Example for SQLite (linux) +# +# DB_CONNECTION=sqlite +# DB_DATABASE="path/to/your/database.sqlite" + +# Example for SQLite (windows) +# +# DB_CONNECTION=sqlite +# DB_DATABASE="C:\\path\\to\\your\\database.sqlite" + +DB_CONNECTION= +DB_DATABASE= +DB_HOST= +DB_PORT= +DB_USERNAME= +DB_PASSWORD= + +# The absolute path to the root CA bundle if you're connecting to the MySQL database via SSL. + +MYSQL_ATTR_SSL_CA= #### Mail settings #### diff --git a/.gitignore b/.gitignore index cd20911b..abeb72fa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /public/hot /public/storage /storage/*.key +/storage/**/.env* /tests/Coverage /tests/EndToEnd/**/*.html /tests/EndToEnd/**/output.xml diff --git a/app/Console/Commands/Install.php b/app/Console/Commands/Install.php new file mode 100644 index 00000000..c29f65fb --- /dev/null +++ b/app/Console/Commands/Install.php @@ -0,0 +1,333 @@ +newLine(2); + $this->alert('2FAuth installation'); + + if ($this->option('no-interaction')) { + $this->newLine(); + $this->info('(Running in no-interaction mode)'); + $this->newLine(); + } + + $this->info('Start processing'); + try { + $this->clearCaches(); + $this->loadEnvFile(); + $this->maybeGenerateAppKey(); + + if ((! $this->envFileExists || $this->confirm('Existing .env file found. Do you wish to review its vars?', true)) && ! $this->option('no-interaction')) { + $this->setMainEnvVars(); + $this->setDbEnvVars(); + } + + $this->migrateDatabase(); + $this->installPassport(); + $this->createStorageLink(); + $this->cacheConfig(); + + $this->dotenvEditor->save(); + } catch (Throwable $e) { + Log::error($e); + + $this->newLine(); + $this->line('Sorry, something went wrong :('); + $this->newLine(); + $this->components->error($e->getMessage()); + $this->components->info('See the error log at storage/logs/laravel.log for the full stack trace.'); + + $this->newLine(); + $this->line('Fix the error and rerun the \'2fauth:install\' command to complete installation.'); + $this->newLine(); + $this->line('As a reminder, you can always install/upgrade manually following the guide at:'); + $this->info(config('2fauth.installDocUrl')); + $this->newLine(); + $this->line('You can also ask for some help at:'); + $this->info(config('2fauth.repository') . '/issues'); + + return self::FAILURE; + } + + $this->newLine(); + $this->output->success('Installation complete successfully'); + $this->line('Visit ' . config('app.url') . ' to start using 2FAuth'); + $this->newLine(); + $this->line('-----------------------------------'); + $this->line('.▀█▀.█▄█.█▀█.█▄.█.█▄▀ █▄█.█▀█.█─█'); + $this->line('─.█.─█▀█.█▀█.█.▀█.█▀▄ ─█.─█▄█.█▄█ for using 2FAuth'); + $this->newLine(); + $this->line('Want to support its development?'); + $this->line('You can Buy me a coffee => https://ko-fi.com/bubka'); + + return self::SUCCESS; + } + + /** + * + */ + protected function installPassport() : void + { + $this->components->task('Setting up Passport', function () : void { + $this->callSilently('passport:install'); + }); + } + + /** + * + */ + protected function cacheConfig() : void + { + $this->components->task('Caching config', function () : void { + $this->callSilently('config:cache'); + }); + } + + /** + * + */ + protected function createStorageLink() : void + { + if (!file_exists(public_path('storage'))) { + $this->components->task('Creating storage link', function () : void { + $this->callSilently('storage:link'); + }); + } + } + + /** + * + */ + protected function setMainEnvVars() : void + { + while (true) { + $appUrl = trim($this->ask('URL of this 2FAuth instance', config('app.url')), '/'); + if (filter_var($appUrl, FILTER_VALIDATE_URL)) { + break; + } + else { + $this->components->error('This is not a valid URL, please retry'); + } + } + + $urlPath = parse_url($appUrl, PHP_URL_PATH); + + if ($urlPath && $urlPath != '/') { + $urlPath = trim($urlPath, '/'); + $this->components->info('2Fauth will be served under subdirectory /' . $urlPath); + $this->dotenvEditor->setKey('APP_SUBDIRECTORY', $urlPath); + } + + $this->dotenvEditor->setKey('APP_URL', $appUrl); + } + + + /** + * Prompt user for valid database credentials and set them to .env file. + */ + protected function setDbEnvVars() : void + { + $config = [ + 'DB_HOST' => '', + 'DB_PORT' => '', + 'DB_USERNAME' => '', + 'DB_PASSWORD' => '', + ]; + + $config['DB_CONNECTION'] = $this->choice( + 'Type of database', + [ + 'mysql' => 'MySQL/MariaDB', + 'pgsql' => 'PostgreSQL', + 'sqlsrv' => 'SQL Server', + 'sqlite' => 'SQLite', + ], + config('database.default') + ); + + if ($config['DB_CONNECTION'] === 'sqlite') { + $databasePath = $this->dotenvEditor->getValue('DB_CONNECTION') != 'sqlite' + ? database_path('database.sqlite') + : config('database.connections.sqlite.database'); + + $config['DB_DATABASE'] = $this->ask('Absolute path to the DB file', $databasePath); + } else { + $defaultName = $this->dotenvEditor->getValue('DB_DATABASE') ?: '2fauth'; + $databaseName = $this->dotenvEditor->getValue('DB_CONNECTION') == 'sqlite' + ? '2fauth' + : $defaultName; + + $config['DB_HOST'] = $this->ask('Database host', config('database.connections.' . $config['DB_CONNECTION'] . '.host')); + $config['DB_PORT'] = (string) $this->ask('Database port', config('database.connections.' . $config['DB_CONNECTION'] . '.port')); + $config['DB_DATABASE'] = $this->ask('Database name', $databaseName); + $config['DB_USERNAME'] = $this->ask('Database user', config('database.connections.' . $config['DB_CONNECTION'] . '.username')); + $config['DB_PASSWORD'] = (string) $this->secret('Database password', config('database.connections.' . $config['DB_CONNECTION'] . '.password')); + // $config['DB_PASSWORD'] = (string) $this->secret('Database password', true); + } + + $this->dotenvEditor->setKeys($config); + $this->dotenvEditor->save(); + + // Set the config so that the next DB attempt uses refreshed credentials + config([ + 'database.default' => $config['DB_CONNECTION'], + 'database.connections.' . $config['DB_CONNECTION'] . '.database' => $config['DB_DATABASE'], + 'database.connections.' . $config['DB_CONNECTION'] . '.host' => $config['DB_HOST'], + 'database.connections.' . $config['DB_CONNECTION'] . '.port' => $config['DB_PORT'], + 'database.connections.' . $config['DB_CONNECTION'] . '.username' => $config['DB_USERNAME'], + 'database.connections.' . $config['DB_CONNECTION'] . '.password' => $config['DB_PASSWORD'], + ]); + $this->laravel['db']->purge(); + } + + /** + * + */ + protected function migrateDatabase() : mixed + { + if (! $this->confirmToProceed()) { + return 1; + } + + return $this->call('migrate', ['--force' => $this->option('force')]); + } + + /** + * + */ + protected function clearCaches() : void + { + $this->components->task('Clearing caches', function () : void { + $this->callSilently('config:clear'); + $this->callSilently('cache:clear'); + }); + } + + /** + * + */ + protected function loadEnvFile() : void + { + $this->envFileExists = file_exists(base_path('.env')); + + if (! $this->envFileExists && $this->option('no-interaction')) { + throw new Exception('--no-interaction option cannot be used during first install'); + } + + if (! $this->envFileExists) { + $this->input->setOption('force', true); + } + + $this->components->task('Preparing .env file', static function () : void { + if (!file_exists(base_path('.env'))) { + copy(base_path('.env.example'), base_path('.env')); + } + }); + + $this->dotenvEditor->load(base_path('.env')); + } + + /** + * + */ + protected function maybeGenerateAppKey() : void + { + $key = config('app.key'); + + $this->components->task($key ? 'Retrieving app key' : 'Generating app key', function () use (&$key) : void { + if (!$key) { + // Generate the key manually to prevent some clashes with `php artisan key:generate` + $key = $this->generateRandomKey(); + $this->dotenvEditor->setKey('APP_KEY', $key); + config('app.key', $key); + } + }); + } + + /** + * Generate a random key for the application. + */ + protected function generateRandomKey() : string + { + return 'base64:' . base64_encode(Encrypter::generateKey(config('app.cipher'))); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 037e17df..29eaef8c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,6 +11,24 @@ | */ +if (! function_exists('envUnlessEmpty')) { + /** + * @param string $key + * @param null $default + * + * @return mixed|null + */ + function envUnlessEmpty(string $key, $default = null) + { + $result = env($key, $default); + if (is_string($result) && '' === $result) { + $result = $default; + } + + return $result; + } +} + $app = new Illuminate\Foundation\Application( $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__) ); diff --git a/composer.json b/composer.json index 87b5a462..02a5f19e 100644 --- a/composer.json +++ b/composer.json @@ -21,12 +21,13 @@ "ext-tokenizer": "*", "ext-xml": "*", "chillerlan/php-qrcode": "^4.3", - "laragear/webauthn": "^1.2.0", "doctormckay/steam-totp": "^1.0", "doctrine/dbal": "^3.4", "google/protobuf": "^3.21", "guzzlehttp/guzzle": "^7.2", + "jackiedo/dotenv-editor": "^2.1", "khanamiryan/qrcode-detector-decoder": "^2.0.2", + "laragear/webauthn": "^1.2.0", "laravel/framework": "^9.0", "laravel/passport": "^11.2", "laravel/tinker": "^2.7", @@ -94,4 +95,4 @@ "vendor/bin/phpunit --coverage-html tests/Coverage/" ] } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index f9676dd4..b294ffc1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "60cc94b258157b8eb06ef75ef7fab121", + "content-hash": "209aec6ab7d505a0e25f420b43e4a337", "packages": [ { "name": "brick/math", @@ -1671,6 +1671,119 @@ ], "time": "2021-10-07T12:57:01+00:00" }, + { + "name": "jackiedo/dotenv-editor", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/JackieDo/Laravel-Dotenv-Editor.git", + "reference": "24667e3704b281e0d6c56de340bed1dc718072a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/JackieDo/Laravel-Dotenv-Editor/zipball/24667e3704b281e0d6c56de340bed1dc718072a1", + "reference": "24667e3704b281e0d6c56de340bed1dc718072a1", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^9.0|^8.0|^7.0|^6.0|^5.8", + "illuminate/contracts": "^10.0|^9.0|^8.0|^7.0|^6.0|^5.8", + "illuminate/support": "^10.0|^9.0|^8.0|^7.0|^6.0|^5.8", + "jackiedo/path-helper": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + }, + "laravel": { + "providers": [ + "Jackiedo\\DotenvEditor\\DotenvEditorServiceProvider" + ], + "aliases": { + "DotenvEditor": "Jackiedo\\DotenvEditor\\Facades\\DotenvEditor" + } + } + }, + "autoload": { + "psr-4": { + "Jackiedo\\DotenvEditor\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jackie Do", + "email": "anhvudo@gmail.com" + } + ], + "description": "The .env file editor tool for Laravel 5.8+", + "keywords": [ + "dotenv", + "dotenv-editor", + "laravel" + ], + "support": { + "issues": "https://github.com/JackieDo/Laravel-Dotenv-Editor/issues", + "source": "https://github.com/JackieDo/Laravel-Dotenv-Editor/tree/2.1.0" + }, + "time": "2023-02-19T00:32:40+00:00" + }, + { + "name": "jackiedo/path-helper", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/JackieDo/Path-Helper.git", + "reference": "43179cfa17d01f94f4889f286430c2cec1071fb2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/JackieDo/Path-Helper/zipball/43179cfa17d01f94f4889f286430c2cec1071fb2", + "reference": "43179cfa17d01f94f4889f286430c2cec1071fb2", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Jackiedo\\PathHelper\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jackie Do", + "email": "anhvudo@gmail.com" + } + ], + "description": "Helper class for working with local paths in PHP", + "homepage": "https://github.com/JackieDo/path-helper", + "keywords": [ + "helper", + "helpers", + "library", + "path", + "paths", + "php" + ], + "support": { + "issues": "https://github.com/JackieDo/Path-Helper/issues", + "source": "https://github.com/JackieDo/Path-Helper/tree/v1.0.0" + }, + "time": "2022-03-07T20:28:08+00:00" + }, { "name": "khanamiryan/qrcode-detector-decoder", "version": "2.0.2", diff --git a/config/2fauth.php b/config/2fauth.php index c80e6466..b208b0c9 100644 --- a/config/2fauth.php +++ b/config/2fauth.php @@ -12,6 +12,7 @@ return [ 'version' => '4.0.2', 'repository' => 'https://github.com/Bubka/2FAuth', 'latestReleaseUrl' => 'https://api.github.com/repos/Bubka/2FAuth/releases/latest', + 'installDocUrl' => 'https://docs.2fauth.app/getting-started/installation/self-hosted-server/', /* |-------------------------------------------------------------------------- diff --git a/config/app.php b/config/app.php index 94719955..75749772 100644 --- a/config/app.php +++ b/config/app.php @@ -121,7 +121,7 @@ return [ | */ - 'key' => strpos(env('APP_KEY'), 'base64:') !== false ? env('APP_KEY') : substr(env('APP_KEY'), 0, 32), + 'key' => str_starts_with(env('APP_KEY')??'', 'base64:') ? env('APP_KEY') : substr(env('APP_KEY')??'', 0, 32), 'cipher' => 'AES-256-CBC', diff --git a/config/database.php b/config/database.php index 92a285db..5fe023cf 100644 --- a/config/database.php +++ b/config/database.php @@ -15,7 +15,7 @@ return [ | */ - 'default' => env('DB_CONNECTION', 'mysql'), + 'default' => envUnlessEmpty('DB_CONNECTION', 'mysql'), /* |-------------------------------------------------------------------------- @@ -38,7 +38,7 @@ return [ 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DATABASE_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'database' => envUnlessEmpty('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ], @@ -53,10 +53,10 @@ return [ 'mysql' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), + 'host' => envUnlessEmpty('DB_HOST', '127.0.0.1'), + 'port' => envUnlessEmpty('DB_PORT', '3306'), + 'database' => envUnlessEmpty('DB_DATABASE', '2fauth'), + 'username' => envUnlessEmpty('DB_USERNAME', '2fauth'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'charset' => 'utf8mb4', @@ -66,17 +66,17 @@ return [ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + PDO::MYSQL_ATTR_SSL_CA => envUnlessEmpty('MYSQL_ATTR_SSL_CA', null), ]) : [], ], 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), + 'host' => envUnlessEmpty('DB_HOST', '127.0.0.1'), + 'port' => envUnlessEmpty('DB_PORT', '5432'), + 'database' => envUnlessEmpty('DB_DATABASE', '2fauth'), + 'username' => envUnlessEmpty('DB_USERNAME', '2fauth'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', @@ -88,10 +88,10 @@ return [ 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', '1433'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), + 'host' => envUnlessEmpty('DB_HOST', 'localhost'), + 'port' => envUnlessEmpty('DB_PORT', '1433'), + 'database' => envUnlessEmpty('DB_DATABASE', '2fauth'), + 'username' => envUnlessEmpty('DB_USERNAME', '2fauth'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', diff --git a/config/dotenv-editor.php b/config/dotenv-editor.php new file mode 100644 index 00000000..3367076c --- /dev/null +++ b/config/dotenv-editor.php @@ -0,0 +1,37 @@ + false, + + /* + |---------------------------------------------------------------------- + | Backup location + |---------------------------------------------------------------------- + | + | This value is used when you backup your file. This value is the sub + | path from root folder of project application. + */ + + 'backupPath' => base_path('storage/dotenv-backups/'), + + /* + |---------------------------------------------------------------------- + | Always create backup folder + |---------------------------------------------------------------------- + | + | If this setting is set to true, the backup folder set up in the + | 'backupPath' setting will always be created regardless of whether the + | backup is performed or not. + */ + + 'alwaysCreateBackupFolder' => false, +];