diff --git a/INSTALL.md b/INSTALL.md index b6107e006..50e86e24c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -29,7 +29,7 @@ is self-signed). ## 1. Requirements - Apache with mod-rewrite enabled and "Options All" so you can use a local .htaccess file -- PHP 5.6.1+ (PHP 7.1+ recommended for performance and official support). +- PHP 7+ (PHP 7.1+ recommended for performance and official support). - PHP *command line* with `register_argc_argv = true` in php.ini - curl, gd (with at least jpeg support), mysql, mbstring, xml, zip and openssl extensions - Some form of email server or email gateway such that PHP mail() works diff --git a/composer.json b/composer.json index b0c51f9bf..c1366c677 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "issues": "https://github.com/friendica/friendica/issues" }, "require": { - "php": ">=5.6.1", + "php": ">=7.0", "ext-ctype": "*", "ext-curl": "*", "ext-dom": "*", @@ -27,6 +27,7 @@ "ext-simplexml": "*", "ext-xml": "*", "asika/simple-console": "^1.0", + "bacon/bacon-qr-code": "^1.0", "divineomega/password_exposed": "^2.4", "ezyang/htmlpurifier": "~4.7.0", "friendica/json-ld": "^1.0", @@ -36,8 +37,9 @@ "mobiledetect/mobiledetectlib": "2.8.*", "monolog/monolog": "^1.24", "nikic/fast-route": "^1.3", - "paragonie/random_compat": "^2.0", "pear/text_languagedetect": "1.*", + "pragmarx/google2fa": "^5.0", + "pragmarx/recovery": "^0.1.0", "psr/container": "^1.0", "seld/cli-prompt": "^1.0", "smarty/smarty": "^3.1", diff --git a/composer.lock b/composer.lock index 06952e05e..2fe210f5d 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": "7fc5e358b089ca47cdb5cac0e22d15d8", + "content-hash": "d7302553201de079b72871c0b2922ce7", "packages": [ { "name": "asika/simple-console", @@ -39,6 +39,52 @@ "description": "One file console framework to help you write build scripts.", "time": "2018-03-08T12:05:40+00:00" }, + { + "name": "bacon/bacon-qr-code", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "5a91b62b9d37cee635bbf8d553f4546057250bee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/5a91b62b9d37cee635bbf8d553f4546057250bee", + "reference": "5a91b62b9d37cee635bbf8d553f4546057250bee", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^5.4|^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8" + }, + "suggest": { + "ext-gd": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-0": { + "BaconQrCode": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "http://www.dasprids.de", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "time": "2017-10-17T09:59:25+00:00" + }, { "name": "bower-asset/Chart-js", "version": "v2.7.2", @@ -1129,22 +1175,6 @@ "require": { "npm-asset/ev-emitter": ">=1.0.0,<2.0.0" }, - "require-dev": { - "npm-asset/chalk": ">=1.1.1,<2.0.0", - "npm-asset/cheerio": ">=0.19.0,<0.20.0", - "npm-asset/gulp": ">=3.9.0,<4.0.0", - "npm-asset/gulp-jshint": ">=1.11.2,<2.0.0", - "npm-asset/gulp-json-lint": ">=0.1.0,<0.2.0", - "npm-asset/gulp-rename": ">=1.2.2,<2.0.0", - "npm-asset/gulp-replace": ">=0.5.4,<0.6.0", - "npm-asset/gulp-requirejs-optimize": "dev-github:metafizzy/gulp-requirejs-optimize", - "npm-asset/gulp-uglify": ">=1.4.2,<2.0.0", - "npm-asset/gulp-util": ">=3.0.7,<4.0.0", - "npm-asset/highlight.js": ">=8.9.1,<9.0.0", - "npm-asset/marked": ">=0.3.5,<0.4.0", - "npm-asset/minimist": ">=1.2.0,<2.0.0", - "npm-asset/transfob": ">=1.0.0,<2.0.0" - }, "type": "npm-asset-library", "extra": { "npm-asset-bugs": { @@ -1190,14 +1220,6 @@ "reference": null, "shasum": "2736e332aaee73ccf0a14a5f0066391a0a13f4a3" }, - "require-dev": { - "npm-asset/grunt": "~0.4.2", - "npm-asset/grunt-contrib-cssmin": "~0.9.0", - "npm-asset/grunt-contrib-jshint": "~0.6.3", - "npm-asset/grunt-contrib-less": "~0.11.0", - "npm-asset/grunt-contrib-uglify": "~0.4.0", - "npm-asset/grunt-contrib-watch": "~0.6.1" - }, "type": "npm-asset-library", "extra": { "npm-asset-bugs": { @@ -1231,32 +1253,6 @@ "reference": null, "shasum": "2c89d6889b5eac522a7eea32c14521559c6cbf02" }, - "require-dev": { - "npm-asset/commitplease": "2.0.0", - "npm-asset/core-js": "0.9.17", - "npm-asset/grunt": "0.4.5", - "npm-asset/grunt-babel": "5.0.1", - "npm-asset/grunt-cli": "0.1.13", - "npm-asset/grunt-compare-size": "0.4.0", - "npm-asset/grunt-contrib-jshint": "0.11.2", - "npm-asset/grunt-contrib-uglify": "0.9.2", - "npm-asset/grunt-contrib-watch": "0.6.1", - "npm-asset/grunt-git-authors": "2.0.1", - "npm-asset/grunt-jscs": "2.1.0", - "npm-asset/grunt-jsonlint": "1.0.4", - "npm-asset/grunt-npmcopy": "0.1.0", - "npm-asset/gzip-js": "0.3.2", - "npm-asset/jsdom": "5.6.1", - "npm-asset/load-grunt-tasks": "1.0.0", - "npm-asset/qunit-assert-step": "1.0.3", - "npm-asset/qunitjs": "1.17.1", - "npm-asset/requirejs": "2.1.17", - "npm-asset/sinon": "1.10.3", - "npm-asset/sizzle": "2.2.1", - "npm-asset/strip-json-comments": "1.0.3", - "npm-asset/testswarm": "1.1.0", - "npm-asset/win-spawn": "2.0.0" - }, "type": "npm-asset-library", "extra": { "npm-asset-bugs": { @@ -1407,12 +1403,6 @@ "reference": null, "shasum": "06f0335f16e353a695e7206bf50503cb523a6ee5" }, - "require-dev": { - "npm-asset/grunt": "~0.4.1", - "npm-asset/grunt-contrib-connect": "~0.5.0", - "npm-asset/grunt-contrib-jshint": "~0.7.1", - "npm-asset/grunt-contrib-uglify": "~0.2.7" - }, "type": "npm-asset-library", "extra": { "npm-asset-bugs": { @@ -1695,33 +1685,29 @@ }, { "name": "paragonie/random_compat", - "version": "v2.0.17", + "version": "v9.99.99", "source": { "type": "git", "url": "https://github.com/paragonie/random_compat.git", - "reference": "29af24f25bab834fcbb38ad2a69fa93b867e070d" + "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/random_compat/zipball/29af24f25bab834fcbb38ad2a69fa93b867e070d", - "reference": "29af24f25bab834fcbb38ad2a69fa93b867e070d", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", + "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", "shasum": "" }, "require": { - "php": ">=5.2.0" + "php": "^7" }, "require-dev": { - "phpunit/phpunit": "4.*|5.*" + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" }, "suggest": { "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." }, "type": "library", - "autoload": { - "files": [ - "lib/random.php" - ] - }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" @@ -1740,7 +1726,7 @@ "pseudorandom", "random" ], - "time": "2018-07-04T16:31:37+00:00" + "time": "2018-07-02T15:55:56+00:00" }, { "name": "paragonie/sodium_compat", @@ -1923,6 +1909,189 @@ "homepage": "http://pear.php.net/package/Text_LanguageDetect", "time": "2017-03-02T16:14:08+00:00" }, + { + "name": "pragmarx/google2fa", + "version": "v5.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "17c969c82f427dd916afe4be50bafc6299aef1b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/17c969c82f427dd916afe4be50bafc6299aef1b4", + "reference": "17c969c82f427dd916afe4be50bafc6299aef1b4", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "~1.0|~2.0", + "paragonie/random_compat": ">=1", + "php": ">=5.4", + "symfony/polyfill-php56": "~1.2" + }, + "require-dev": { + "phpunit/phpunit": "~4|~5|~6" + }, + "type": "library", + "extra": { + "component": "package", + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/", + "PragmaRX\\Google2FA\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "time": "2019-03-19T22:44:16+00:00" + }, + { + "name": "pragmarx/random", + "version": "v0.2.2", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/random.git", + "reference": "daf08a189c5d2d40d1a827db46364d3a741a51b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/random/zipball/daf08a189c5d2d40d1a827db46364d3a741a51b7", + "reference": "daf08a189c5d2d40d1a827db46364d3a741a51b7", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "fzaninotto/faker": "~1.7", + "phpunit/phpunit": "~6.4", + "pragmarx/trivia": "~0.1", + "squizlabs/php_codesniffer": "^2.3" + }, + "suggest": { + "fzaninotto/faker": "Allows you to get dozens of randomized types", + "pragmarx/trivia": "For the trivia database" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Random\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "homepage": "https://antoniocarlosribeiro.com", + "role": "Developer" + } + ], + "description": "Create random chars, numbers, strings", + "homepage": "https://github.com/antonioribeiro/random", + "keywords": [ + "Randomize", + "faker", + "pragmarx", + "random", + "random number", + "random pattern", + "random string" + ], + "time": "2017-11-21T05:26:22+00:00" + }, + { + "name": "pragmarx/recovery", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/recovery.git", + "reference": "e16573a1ae5345cc3b100eec6d0296a1a15a90fe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/recovery/zipball/e16573a1ae5345cc3b100eec6d0296a1a15a90fe", + "reference": "e16573a1ae5345cc3b100eec6d0296a1a15a90fe", + "shasum": "" + }, + "require": { + "php": "~7.0", + "pragmarx/random": "~0.1" + }, + "require-dev": { + "phpunit/phpunit": ">=5.4.3", + "squizlabs/php_codesniffer": "^2.3", + "tightenco/collect": "^5" + }, + "suggest": { + "tightenco/collect": "Allows to generate recovery codes as collections" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "PragmaRX\\Recovery\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "homepage": "https://antoniocarlosribeiro.com", + "role": "Developer" + } + ], + "description": "Create recovery codes for two factor auth", + "homepage": "https://github.com/antonioribeiro/recovery", + "keywords": [ + "2fa", + "account recovery", + "auth", + "backup codes", + "google2fa", + "pragmarx", + "recovery", + "recovery codes", + "two factor auth" + ], + "time": "2017-09-19T16:58:00+00:00" + }, { "name": "psr/cache", "version": "1.0.1", @@ -2215,9 +2384,159 @@ "templating" ], "time": "2018-09-12T20:54:16+00:00" + }, + { + "name": "symfony/polyfill-php56", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php56.git", + "reference": "f4dddbc5c3471e1b700a147a20ae17cdb72dbe42" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/f4dddbc5c3471e1b700a147a20ae17cdb72dbe42", + "reference": "f4dddbc5c3471e1b700a147a20ae17cdb72dbe42", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "symfony/polyfill-util": "~1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php56\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 5.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2019-02-06T07:57:58+00:00" + }, + { + "name": "symfony/polyfill-util", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-util.git", + "reference": "b46c6cae28a3106735323f00a0c38eccf2328897" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-util/zipball/b46c6cae28a3106735323f00a0c38eccf2328897", + "reference": "b46c6cae28a3106735323f00a0c38eccf2328897", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.11-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Util\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony utilities for portability of PHP codes", + "homepage": "https://symfony.com", + "keywords": [ + "compat", + "compatibility", + "polyfill", + "shim" + ], + "time": "2019-02-08T14:16:39+00:00" } ], "packages-dev": [ + { + "name": "dasprid/enum", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "631ef6e638e9494b0310837fa531bedd908fc22b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/631ef6e638e9494b0310837fa531bedd908fc22b", + "reference": "631ef6e638e9494b0310837fa531bedd908fc22b", + "shasum": "" + }, + "require-dev": { + "phpunit/phpunit": "^6.4", + "squizlabs/php_codesniffer": "^3.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "time": "2017-10-25T22:45:27+00:00" + }, { "name": "doctrine/instantiator", "version": "1.0.5", @@ -2373,12 +2692,12 @@ "version": "v1.6.5", "source": { "type": "git", - "url": "https://github.com/mikey179/vfsStream.git", + "url": "https://github.com/bovigo/vfsStream.git", "reference": "d5fec95f541d4d71c4823bb5e30cf9b9e5b96145" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mikey179/vfsStream/zipball/d5fec95f541d4d71c4823bb5e30cf9b9e5b96145", + "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/d5fec95f541d4d71c4823bb5e30cf9b9e5b96145", "reference": "d5fec95f541d4d71c4823bb5e30cf9b9e5b96145", "shasum": "" }, @@ -3281,7 +3600,7 @@ } ], "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", + "homepage": "http://www.github.com/sebastianbergmann/comparator", "keywords": [ "comparator", "compare", @@ -3383,7 +3702,7 @@ } ], "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "https://github.com/sebastianbergmann/environment", + "homepage": "http://www.github.com/sebastianbergmann/environment", "keywords": [ "Xdebug", "environment", @@ -3451,7 +3770,7 @@ } ], "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "https://github.com/sebastianbergmann/exporter", + "homepage": "http://www.github.com/sebastianbergmann/exporter", "keywords": [ "export", "exporter" @@ -3503,7 +3822,7 @@ } ], "description": "Snapshotting of global state", - "homepage": "https://github.com/sebastianbergmann/global-state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], @@ -3605,7 +3924,7 @@ } ], "description": "Provides functionality to recursively process PHP variables", - "homepage": "https://github.com/sebastianbergmann/recursion-context", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", "time": "2016-11-19T07:33:16+00:00" }, { @@ -3869,7 +4188,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=5.6.1", + "php": ">=7.0", "ext-ctype": "*", "ext-curl": "*", "ext-dom": "*", diff --git a/config/dbstructure.config.php b/config/dbstructure.config.php index 86965a201..38723ddc1 100755 --- a/config/dbstructure.config.php +++ b/config/dbstructure.config.php @@ -34,10 +34,22 @@ use Friendica\Database\DBA; if (!defined('DB_UPDATE_VERSION')) { - define('DB_UPDATE_VERSION', 1311); + define('DB_UPDATE_VERSION', 1312); } return [ + "2fa_recovery_codes" => [ + "comment" => "Two-factor authentication recovery codes", + "fields" => [ + "uid" => ["type" => "int unsigned", "not null" => "1", "comment" => "User ID"], + "code" => ["type" => "varchar(50)", "not null" => "1", "comment" => "Recovery code string"], + "generated" => ["type" => "datetime", "not null" => "1", "comment" => "Datetime the code was generated"], + "used" => ["type" => "datetime", "comment" => "Datetime the code was used"], + ], + "indexes" => [ + "PRIMARY" => ["uid", "code"] + ] + ], "addon" => [ "comment" => "registered addons", "fields" => [ diff --git a/doc/Install.md b/doc/Install.md index 8d7418260..5e9448f76 100644 --- a/doc/Install.md +++ b/doc/Install.md @@ -25,7 +25,7 @@ Requirements --- * Apache with mod-rewrite enabled and "Options All" so you can use a local .htaccess file -* PHP 5.6.1+ (PHP 7.1+ is recommended for performance and official support) +* PHP 7+ (PHP 7.1+ is recommended for performance and official support) * PHP *command line* access with register_argc_argv set to true in the php.ini file * Curl, GD, PDO, MySQLi, hash, xml, zip and OpenSSL extensions * The POSIX module of PHP needs to be activated (e.g. [RHEL, CentOS](http://www.bigsoft.co.uk/blog/index.php/2014/12/08/posix-php-commands-not-working-under-centos-7) have disabled it) diff --git a/doc/Two-Factor-Authentication.md b/doc/Two-Factor-Authentication.md new file mode 100644 index 000000000..32aa7308a --- /dev/null +++ b/doc/Two-Factor-Authentication.md @@ -0,0 +1,60 @@ +# Configuring two-factor authentication + +* [Home](help) + +You can configure two-factor authentication using a mobile app. +A time-based one-time password (TOTP) application automatically generates an authentication code that changes after a certain period of time. + +**Tip**: To configure authentication via TOTP on multiple devices, during setup, scan the QR code using each device at the same time. +If 2FA is already enabled and you want to add another device, you must re-configure 2FA from your security settings. + +## Enabling two-factor authentication + +### 1. Download an authenticator app + +Any authenticator app should work with Friendica. +Notheless, we recommend: + + - For iOS, [Matt Rubin's MIT-licensed Authenticator app](https://mattrubin.me/authenticator). + - For Android, [andOTP](https://github.com/andOTP/andOTP). + +### 2. Record your one-use recovery codes + +From your [two-factor authentication user settings](/settings/2fa), enter your password and click on "Enable two-factor authentication". + +You will be presented with a list of one-use recovery codes. +Please save those in the same place you are saving your Friendica password (ideally, in a password manager like [KeePass](https://keepass.info)). + +When you're done, click on "Next". + +### 3. Setup your authenticator app + +You have three methods to setup your authenticator app: + +1. Scan the QR Code with your device camera. + This will automatically configure your account on the app. +2. Click/tap on the provided **totp://** URl. + Ideally your authenticator app should be called with this URL and set up your account. +3. Enter your account settings manually. + Friendica is using default settings for token type, code digit count and hashing algorithm but you may be required to enter them in your app. + +**Tip**: If you have multiple devices, configure them all at this point. + +Then verify your app is correctly configured by submitting a code provided by your app. +This will conclude two-factor authentication configuration. + +**Note:** If you leave this screen at any point without having submitted a verification code, two-factor authentication won't be enabled on your account. +To complete the configuration, just come back to your [two-factor authentication user settings](/settings/2fa) and click on "Finish configuration" after entering your current password. + +## Disabling two-factor authentication + +You can disable two-factor authentication at any time by going to your [two-factor authentication user settings](/settings/2fa) and click on "Disable two-factor authentication" after entering your current password. + +You should remove your Friendica account from your authenticator app as it won't work again even if you reenable two-factor authentication. +In this case you will have to configure your authenticator app again using the process above. + +## Managing your one-time recovery codes + +When two-factor authentication is enabled, you can show your recovery codes, including the ones you've already used. + +You can freely regenerate a new set of fresh recovery codes, just be sure to replace the previous ones where you saved them as they won't be active anymore. diff --git a/doc/de/Install.md b/doc/de/Install.md index 24bd078d9..b8a827ab1 100644 --- a/doc/de/Install.md +++ b/doc/de/Install.md @@ -28,7 +28,7 @@ Requirements --- * Apache mit einer aktiverten mod-rewrite-Funktion und dem Eintrag "Options All", so dass du die lokale .htaccess-Datei nutzen kannst -* PHP 5.6.1+ (PHP 7.1+ wird für Performance und offiziellen Support empfohlen) +* PHP 7+ (PHP 7.1+ wird für Performance und offiziellen Support empfohlen) * PHP *Kommandozeilen*-Zugang mit register_argc_argv auf "true" gesetzt in der php.ini-Datei * Curl, GD, PDO, MySQLi, xml, zip und OpenSSL-Erweiterung * Das POSIX Modul muss aktiviert sein ([CentOS, RHEL](http://www.bigsoft.co.uk/blog/index.php/2014/12/08/posix-php-commands-not-working-under-centos-7http://www.bigsoft.co.uk/blog/index.php/2014/12/08/posix-php-commands-not-working-under-centos-7) haben dies z.B. deaktiviert) diff --git a/include/api.php b/include/api.php index f360dc411..eccd77675 100644 --- a/include/api.php +++ b/include/api.php @@ -11,7 +11,6 @@ use Friendica\Content\ContactSelector; use Friendica\Content\Feature; use Friendica\Content\Text\BBCode; use Friendica\Content\Text\HTML; -use Friendica\Core\Authentication; use Friendica\Core\Config; use Friendica\Core\Hook; use Friendica\Core\L10n; @@ -19,6 +18,7 @@ use Friendica\Core\Logger; use Friendica\Core\NotificationsManager; use Friendica\Core\PConfig; use Friendica\Core\Protocol; +use Friendica\Core\Session; use Friendica\Core\System; use Friendica\Core\Worker; use Friendica\Database\DBA; @@ -250,7 +250,7 @@ function api_login(App $a) throw new UnauthorizedException("This API requires login"); } - Authentication::setAuthenticatedSessionForUser($record); + Session::setAuthenticatedForUser($a, $record); $_SESSION["allow_api"] = true; diff --git a/mod/manage.php b/mod/manage.php index 52ddfdf03..58590264a 100644 --- a/mod/manage.php +++ b/mod/manage.php @@ -2,11 +2,12 @@ /** * @file mod/manage.php */ + use Friendica\App; -use Friendica\Core\Authentication; use Friendica\Core\Hook; use Friendica\Core\L10n; use Friendica\Core\Renderer; +use Friendica\Core\Session; use Friendica\Database\DBA; function manage_post(App $a) { @@ -108,7 +109,7 @@ function manage_post(App $a) { unset($_SESSION['sysmsg_info']); } - Authentication::setAuthenticatedSessionForUser($r[0], true, true); + Session::setAuthenticatedForUser($a, $r[0], true, true); if ($limited_id) { $_SESSION['submanage'] = $original_id; diff --git a/mod/openid.php b/mod/openid.php index 7300c686b..def34ff08 100644 --- a/mod/openid.php +++ b/mod/openid.php @@ -4,10 +4,10 @@ */ use Friendica\App; -use Friendica\Core\Authentication; use Friendica\Core\Config; use Friendica\Core\L10n; use Friendica\Core\Logger; +use Friendica\Core\Session; use Friendica\Database\DBA; use Friendica\Util\Strings; @@ -52,7 +52,7 @@ function openid_content(App $a) { unset($_SESSION['openid']); - Authentication::setAuthenticatedSessionForUser($r[0],true,true); + Session::setAuthenticatedForUser($a, $r[0],true,true); // just in case there was no return url set // and we fell through diff --git a/mod/settings.php b/mod/settings.php index 536c83354..45f11cdb6 100644 --- a/mod/settings.php +++ b/mod/settings.php @@ -67,6 +67,13 @@ function settings_init(App $a) ], ]; + $tabs[] = [ + 'label' => L10n::t('Two-factor authentication'), + 'url' => 'settings/2fa', + 'selected' => (($a->argc > 1) && ($a->argv[1] === '2fa') ? 'active' : ''), + 'accesskey' => 'o', + ]; + $tabs[] = [ 'label' => L10n::t('Profiles'), 'url' => 'profiles', diff --git a/src/App/Router.php b/src/App/Router.php index afea901cf..cd59c3dd9 100644 --- a/src/App/Router.php +++ b/src/App/Router.php @@ -47,6 +47,10 @@ class Router $collector->addRoute(['GET'], '/webfinger' , Module\Xrd::class); $collector->addRoute(['GET'], '/x-social-relay' , Module\WellKnown\XSocialRelay::class); }); + $this->routeCollector->addGroup('/2fa', function (RouteCollector $collector) { + $collector->addRoute(['GET', 'POST'], '[/]' , Module\TwoFactor\Verify::class); + $collector->addRoute(['GET', 'POST'], '/recovery' , Module\TwoFactor\Recovery::class); + }); $this->routeCollector->addGroup('/admin', function (RouteCollector $collector) { $collector->addRoute(['GET'] , '[/]' , Module\Admin\Summary::class); @@ -184,6 +188,14 @@ class Router $collector->addRoute(['GET'], '/{sub1}/{url}' , Module\Proxy::class); $collector->addRoute(['GET'], '/{sub1}/{sub2}/{url}' , Module\Proxy::class); }); + + $this->routeCollector->addGroup('/settings', function (RouteCollector $collector) { + $collector->addGroup('/2fa', function (RouteCollector $collector) { + $collector->addRoute(['GET', 'POST'], '[/]' , Module\Settings\TwoFactor\Index::class); + $collector->addRoute(['GET', 'POST'], '/recovery' , Module\Settings\TwoFactor\Recovery::class); + $collector->addRoute(['GET', 'POST'], '/verify' , Module\Settings\TwoFactor\Verify::class); + }); + }); $this->routeCollector->addRoute(['GET', 'POST'], '/register', Module\Register::class); $this->routeCollector->addRoute(['GET'], '/robots.txt', Module\RobotsTxt::class); $this->routeCollector->addRoute(['GET'], '/rsd.xml', Module\ReallySimpleDiscovery::class); diff --git a/src/Core/Authentication.php b/src/Core/Authentication.php index 1963d34b4..bf7a9eb76 100644 --- a/src/Core/Authentication.php +++ b/src/Core/Authentication.php @@ -5,11 +5,9 @@ namespace Friendica\Core; +use Friendica\App; use Friendica\BaseObject; -use Friendica\Database\DBA; -use Friendica\Model\User; use Friendica\Util\BaseURL; -use Friendica\Util\DateTimeFormat; /** * Handle Authentification, Session and Cookies @@ -55,112 +53,6 @@ class Authentication extends BaseObject setcookie("Friendica", $value, $time, "/", "", (Config::get('system', 'ssl_policy') == BaseUrl::SSL_POLICY_FULL), true); } - /** - * @brief Sets the provided user's authenticated session - * - * @todo Should be moved to Friendica\Core\Session once it's created - * - * @param array $user_record - * @param bool $login_initial - * @param bool $interactive - * @param bool $login_refresh - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - public static function setAuthenticatedSessionForUser($user_record, $login_initial = false, $interactive = false, $login_refresh = false) - { - $a = self::getApp(); - - $_SESSION['uid'] = $user_record['uid']; - $_SESSION['theme'] = $user_record['theme']; - $_SESSION['mobile-theme'] = PConfig::get($user_record['uid'], 'system', 'mobile_theme'); - $_SESSION['authenticated'] = 1; - $_SESSION['page_flags'] = $user_record['page-flags']; - $_SESSION['my_url'] = $a->getbaseUrl() . '/profile/' . $user_record['nickname']; - $_SESSION['my_address'] = $user_record['nickname'] . '@' . substr($a->getbaseUrl(), strpos($a->getbaseUrl(), '://') + 3); - $_SESSION['addr'] = defaults($_SERVER, 'REMOTE_ADDR', '0.0.0.0'); - - $a->user = $user_record; - - if ($interactive) { - if ($a->user['login_date'] <= DBA::NULL_DATETIME) { - $_SESSION['return_path'] = 'profile_photo/new'; - $a->module = 'profile_photo'; - info(L10n::t("Welcome ") . $a->user['username'] . EOL); - info(L10n::t('Please upload a profile photo.') . EOL); - } else { - info(L10n::t("Welcome back ") . $a->user['username'] . EOL); - } - } - - $member_since = strtotime($a->user['register_date']); - if (time() < ($member_since + ( 60 * 60 * 24 * 14))) { - $_SESSION['new_member'] = true; - } else { - $_SESSION['new_member'] = false; - } - if (strlen($a->user['timezone'])) { - date_default_timezone_set($a->user['timezone']); - $a->timezone = $a->user['timezone']; - } - - $masterUid = $user_record['uid']; - - if (!empty($_SESSION['submanage'])) { - $user = DBA::selectFirst('user', ['uid'], ['uid' => $_SESSION['submanage']]); - if (DBA::isResult($user)) { - $masterUid = $user['uid']; - } - } - - $a->identities = User::identities($masterUid); - - if ($login_initial) { - Logger::log('auth_identities: ' . print_r($a->identities, true), Logger::DEBUG); - } - if ($login_refresh) { - Logger::log('auth_identities refresh: ' . print_r($a->identities, true), Logger::DEBUG); - } - - $contact = DBA::selectFirst('contact', [], ['uid' => $_SESSION['uid'], 'self' => true]); - if (DBA::isResult($contact)) { - $a->contact = $contact; - $a->cid = $contact['id']; - $_SESSION['cid'] = $a->cid; - } - - header('X-Account-Management-Status: active; name="' . $a->user['username'] . '"; id="' . $a->user['nickname'] . '"'); - - if ($login_initial || $login_refresh) { - DBA::update('user', ['login_date' => DateTimeFormat::utcNow()], ['uid' => $_SESSION['uid']]); - - // Set the login date for all identities of the user - DBA::update('user', ['login_date' => DateTimeFormat::utcNow()], - ['parent-uid' => $masterUid, 'account_removed' => false]); - } - - if ($login_initial) { - /* - * If the user specified to remember the authentication, then set a cookie - * that expires after one week (the default is when the browser is closed). - * The cookie will be renewed automatically. - * The week ensures that sessions will expire after some inactivity. - */ - if (!empty($_SESSION['remember'])) { - Logger::log('Injecting cookie for remembered user ' . $a->user['nickname']); - self::setCookie(604800, $user_record); - unset($_SESSION['remember']); - } - } - - if ($login_initial) { - Hook::callAll('logged_in', $a->user); - - if (($a->module !== 'home') && isset($_SESSION['return_path'])) { - $a->internalRedirect($_SESSION['return_path']); - } - } - } - /** * @brief Kills the "Friendica" cookie and all session data */ @@ -170,5 +62,26 @@ class Authentication extends BaseObject session_unset(); session_destroy(); } + + public static function twoFactorCheck($uid, App $a) + { + // Check user setting, if 2FA disabled return + if (!PConfig::get($uid, '2fa', 'verified')) { + return; + } + + // Check current path, if 2fa authentication module return + if ($a->argc > 0 && in_array($a->argv[0], ['ping', '2fa', 'view', 'help', 'logout'])) { + return; + } + + // Case 1: 2FA session present and valid: return + if (Session::get('2fa')) { + return; + } + + // Case 2: No valid 2FA session: redirect to code verification page + $a->internalRedirect('2fa'); + } } diff --git a/src/Core/Session.php b/src/Core/Session.php index f1da864bb..8b6c26bc5 100644 --- a/src/Core/Session.php +++ b/src/Core/Session.php @@ -5,9 +5,14 @@ */ namespace Friendica\Core; +use Friendica\App; use Friendica\Core\Session\CacheSessionHandler; use Friendica\Core\Session\DatabaseSessionHandler; +use Friendica\Database\DBA; +use Friendica\Model\Contact; +use Friendica\Model\User; use Friendica\Util\BaseURL; +use Friendica\Util\DateTimeFormat; /** * High-level Session service class @@ -25,7 +30,7 @@ class Session ini_set('session.use_only_cookies', 1); ini_set('session.cookie_httponly', 1); - if (Config::get('system', 'ssl_policy') == BaseUrl::SSL_POLICY_FULL) { + if (Config::get('system', 'ssl_policy') == BaseURL::SSL_POLICY_FULL) { ini_set('session.cookie_secure', 1); } @@ -66,8 +71,142 @@ class Session return $return; } + /** + * Sets a single session variable. + * Overrides value of existing key. + * + * @param string $name + * @param mixed $value + */ public static function set($name, $value) { $_SESSION[$name] = $value; } + + /** + * Sets multiple session variables. + * Overrides values for existing keys. + * + * @param array $values + */ + public static function setMultiple(array $values) + { + $_SESSION = $values + $_SESSION; + } + + /** + * Removes a session variable. + * Ignores missing keys. + * + * @param $name + */ + public static function remove($name) + { + unset($_SESSION[$name]); + } + + /** + * @brief Sets the provided user's authenticated session + * + * @param App $a + * @param array $user_record + * @param bool $login_initial + * @param bool $interactive + * @param bool $login_refresh + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function setAuthenticatedForUser(App $a, array $user_record, $login_initial = false, $interactive = false, $login_refresh = false) + { + self::setMultiple([ + 'uid' => $user_record['uid'], + 'theme' => $user_record['theme'], + 'mobile-theme' => PConfig::get($user_record['uid'], 'system', 'mobile_theme'), + 'authenticated' => 1, + 'page_flags' => $user_record['page-flags'], + 'my_url' => $a->getBaseURL() . '/profile/' . $user_record['nickname'], + 'my_address' => $user_record['nickname'] . '@' . substr($a->getBaseURL(), strpos($a->getBaseURL(), '://') + 3), + 'addr' => defaults($_SERVER, 'REMOTE_ADDR', '0.0.0.0'), + ]); + + $member_since = strtotime($user_record['register_date']); + self::set('new_member', time() < ($member_since + ( 60 * 60 * 24 * 14))); + + if (strlen($user_record['timezone'])) { + date_default_timezone_set($user_record['timezone']); + $a->timezone = $user_record['timezone']; + } + + $masterUid = $user_record['uid']; + + if (self::get('submanage')) { + $user = DBA::selectFirst('user', ['uid'], ['uid' => self::get('submanage')]); + if (DBA::isResult($user)) { + $masterUid = $user['uid']; + } + } + + $a->identities = User::identities($masterUid); + + if ($login_initial) { + $a->getLogger()->info('auth_identities: ' . print_r($a->identities, true)); + } + + if ($login_refresh) { + $a->getLogger()->info('auth_identities refresh: ' . print_r($a->identities, true)); + } + + $contact = DBA::selectFirst('contact', [], ['uid' => $user_record['uid'], 'self' => true]); + if (DBA::isResult($contact)) { + $a->contact = $contact; + $a->cid = $contact['id']; + self::set('cid', $a->cid); + } + + header('X-Account-Management-Status: active; name="' . $user_record['username'] . '"; id="' . $user_record['nickname'] . '"'); + + if ($login_initial || $login_refresh) { + DBA::update('user', ['login_date' => DateTimeFormat::utcNow()], ['uid' => $user_record['uid']]); + + // Set the login date for all identities of the user + DBA::update('user', ['login_date' => DateTimeFormat::utcNow()], + ['parent-uid' => $masterUid, 'account_removed' => false]); + } + + if ($login_initial) { + /* + * If the user specified to remember the authentication, then set a cookie + * that expires after one week (the default is when the browser is closed). + * The cookie will be renewed automatically. + * The week ensures that sessions will expire after some inactivity. + */ + ; + if (self::get('remember')) { + $a->getLogger()->info('Injecting cookie for remembered user ' . $user_record['nickname']); + Authentication::setCookie(604800, $user_record); + self::remove('remember'); + } + } + + Authentication::twoFactorCheck($user_record['uid'], $a); + + if ($interactive) { + if ($user_record['login_date'] <= DBA::NULL_DATETIME) { + info(L10n::t('Welcome %s', $user_record['username'])); + info(L10n::t('Please upload a profile photo.')); + $a->internalRedirect('profile_photo/new'); + } else { + info(L10n::t("Welcome back %s", $user_record['username'])); + } + } + + $a->user = $user_record; + + if ($login_initial) { + Hook::callAll('logged_in', $a->user); + + if ($a->module !== 'home' && self::exists('return_path')) { + $a->internalRedirect(self::get('return_path')); + } + } + } } diff --git a/src/Database/DBA.php b/src/Database/DBA.php index bbf134e8a..228565f94 100644 --- a/src/Database/DBA.php +++ b/src/Database/DBA.php @@ -1484,6 +1484,8 @@ class DBA $new_values = array_merge($new_values, array_values($value)); $placeholders = substr(str_repeat("?, ", count($value)), 0, -2); $condition_string .= "`" . $field . "` IN (" . $placeholders . ")"; + } elseif (is_null($value)) { + $condition_string .= "`" . $field . "` IS NULL"; } else { $new_values[$field] = $value; $condition_string .= "`" . $field . "` = ?"; diff --git a/src/Model/TwoFactorRecoveryCode.php b/src/Model/TwoFactorRecoveryCode.php new file mode 100644 index 000000000..74dead32d --- /dev/null +++ b/src/Model/TwoFactorRecoveryCode.php @@ -0,0 +1,125 @@ + $uid, 'used' => null]); + } + + /** + * Checks the provided code is available to use for login by the provided user + * + * @param int $uid User ID + * @param string $code + * @return bool + * @throws \Exception + */ + public static function existsForUser($uid, $code) + { + return DBA::exists('2fa_recovery_codes', ['uid' => $uid, 'code' => $code, 'used' => null]); + } + + /** + * Returns a complete list of all recovery codes for the provided user, including the used status + * + * @param int $uid User ID + * @return array + * @throws \Exception + */ + public static function getListForUser($uid) + { + $codesStmt = DBA::select('2fa_recovery_codes', ['code', 'used'], ['uid' => $uid]); + + return DBA::toArray($codesStmt); + } + + /** + * Marks the provided code as used for the provided user. + * Returns false if the code doesn't exist for the user or it has been used already. + * + * @param int $uid User ID + * @param string $code + * @return bool + * @throws \Exception + */ + public static function markUsedForUser($uid, $code) + { + DBA::update('2fa_recovery_codes', ['used' => DateTimeFormat::utcNow()], ['uid' => $uid, 'code' => $code, 'used' => null]); + + return DBA::affectedRows() > 0; + } + + /** + * Generates a fresh set of recovery codes for the provided user. + * Generates 12 codes constituted of 2 blocks of 6 characters separated by a dash. + * + * @param int $uid User ID + * @throws \Exception + */ + public static function generateForUser($uid) + { + $Random = (new Random())->pattern('[a-z0-9]'); + + $RecoveryGenerator = new Recovery($Random); + + $codes = $RecoveryGenerator + ->setCount(12) + ->setBlocks(2) + ->setChars(6) + ->lowercase(true) + ->toArray(); + + $generated = DateTimeFormat::utcNow(); + foreach ($codes as $code) { + DBA::insert('2fa_recovery_codes', [ + 'uid' => $uid, + 'code' => $code, + 'generated' => $generated + ]); + } + } + + /** + * Deletes all the recovery codes for the provided user. + * + * @param int $uid User ID + * @throws \Exception + */ + public static function deleteForUser($uid) + { + DBA::delete('2fa_recovery_codes', ['uid' => $uid]); + } + + /** + * Replaces the existing recovery codes for the provided user by a freshly generated set. + * + * @param int $uid User ID + * @throws \Exception + */ + public static function regenerateForUser($uid) + { + self::deleteForUser($uid); + self::generateForUser($uid); + } +} diff --git a/src/Module/BaseSettingsModule.php b/src/Module/BaseSettingsModule.php new file mode 100644 index 000000000..fdf3c8166 --- /dev/null +++ b/src/Module/BaseSettingsModule.php @@ -0,0 +1,110 @@ +page['htmlhead'] .= Renderer::replaceMacros($tpl, [ + '$ispublic' => L10n::t('everybody') + ]); + + $tabs = []; + + $tabs[] = [ + 'label' => L10n::t('Account'), + 'url' => 'settings', + 'selected' => (($a->argc == 1) && ($a->argv[0] === 'settings') ? 'active' : ''), + 'accesskey' => 'o', + ]; + + $tabs[] = [ + 'label' => L10n::t('Two-factor authentication'), + 'url' => 'settings/2fa', + 'selected' => (($a->argc > 1) && ($a->argv[1] === '2fa') ? 'active' : ''), + 'accesskey' => 'o', + ]; + + $tabs[] = [ + 'label' => L10n::t('Profiles'), + 'url' => 'profiles', + 'selected' => (($a->argc == 1) && ($a->argv[0] === 'profiles') ? 'active' : ''), + 'accesskey' => 'p', + ]; + + if (Feature::get()) { + $tabs[] = [ + 'label' => L10n::t('Additional features'), + 'url' => 'settings/features', + 'selected' => (($a->argc > 1) && ($a->argv[1] === 'features') ? 'active' : ''), + 'accesskey' => 't', + ]; + } + + $tabs[] = [ + 'label' => L10n::t('Display'), + 'url' => 'settings/display', + 'selected' => (($a->argc > 1) && ($a->argv[1] === 'display') ? 'active' : ''), + 'accesskey' => 'i', + ]; + + $tabs[] = [ + 'label' => L10n::t('Social Networks'), + 'url' => 'settings/connectors', + 'selected' => (($a->argc > 1) && ($a->argv[1] === 'connectors') ? 'active' : ''), + 'accesskey' => 'w', + ]; + + $tabs[] = [ + 'label' => L10n::t('Addons'), + 'url' => 'settings/addon', + 'selected' => (($a->argc > 1) && ($a->argv[1] === 'addon') ? 'active' : ''), + 'accesskey' => 'l', + ]; + + $tabs[] = [ + 'label' => L10n::t('Delegations'), + 'url' => 'delegate', + 'selected' => (($a->argc == 1) && ($a->argv[0] === 'delegate') ? 'active' : ''), + 'accesskey' => 'd', + ]; + + $tabs[] = [ + 'label' => L10n::t('Connected apps'), + 'url' => 'settings/oauth', + 'selected' => (($a->argc > 1) && ($a->argv[1] === 'oauth') ? 'active' : ''), + 'accesskey' => 'b', + ]; + + $tabs[] = [ + 'label' => L10n::t('Export personal data'), + 'url' => 'uexport', + 'selected' => (($a->argc == 1) && ($a->argv[0] === 'uexport') ? 'active' : ''), + 'accesskey' => 'e', + ]; + + $tabs[] = [ + 'label' => L10n::t('Remove account'), + 'url' => 'removeme', + 'selected' => (($a->argc == 1) && ($a->argv[0] === 'removeme') ? 'active' : ''), + 'accesskey' => 'r', + ]; + + + $tabtpl = Renderer::getMarkupTemplate("generic_links_widget.tpl"); + $a->page['aside'] = Renderer::replaceMacros($tabtpl, [ + '$title' => L10n::t('Settings'), + '$class' => 'settings-widget', + '$items' => $tabs, + ]); + } +} diff --git a/src/Module/Help.php b/src/Module/Help.php index 04c482852..ddf5b06d8 100644 --- a/src/Module/Help.php +++ b/src/Module/Help.php @@ -21,35 +21,35 @@ class Help extends BaseModule $text = ''; $filename = ''; - $app = self::getApp(); - $config = $app->getConfig(); + $a = self::getApp(); + $config = $a->getConfig(); $lang = $config->get('system', 'language'); // @TODO: Replace with parameter from router - if ($app->argc > 1) { + if ($a->argc > 1) { $path = ''; // looping through the argv keys bigger than 0 to build // a path relative to /help - for ($x = 1; $x < $app->argc; $x ++) { + for ($x = 1; $x < $a->argc; $x ++) { if (strlen($path)) { $path .= '/'; } - $path .= $app->getArgumentValue($x); + $path .= $a->getArgumentValue($x); } $title = basename($path); $filename = $path; $text = self::loadDocFile('doc/' . $path . '.md', $lang); - $app->page['title'] = L10n::t('Help:') . ' ' . str_replace('-', ' ', Strings::escapeTags($title)); + $a->page['title'] = L10n::t('Help:') . ' ' . str_replace('-', ' ', Strings::escapeTags($title)); } $home = self::loadDocFile('doc/Home.md', $lang); if (!$text) { $text = $home; $filename = "Home"; - $app->page['title'] = L10n::t('Help'); + $a->page['title'] = L10n::t('Help'); } else { - $app->page['aside'] = Markdown::convert($home, false); + $a->page['aside'] = Markdown::convert($home, false); } if (!strlen($text)) { @@ -85,7 +85,7 @@ class Help extends BaseModule $idNum[$level] ++; $id = implode("_", array_slice($idNum, 1, $level)); - $href = $app->getBaseURL() . "/help/{$filename}#{$id}"; + $href = $a->getBaseURL() . "/help/{$filename}#{$id}"; $toc .= "
  • " . strip_tags($line) . "
  • "; $line = "" . $line; $lastLevel = $level; diff --git a/src/Module/Login.php b/src/Module/Login.php index b0897f2d7..7a3b6ae45 100644 --- a/src/Module/Login.php +++ b/src/Module/Login.php @@ -12,6 +12,7 @@ use Friendica\Core\Hook; use Friendica\Core\L10n; use Friendica\Core\Logger; use Friendica\Core\Renderer; +use Friendica\Core\Session; use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\Model\User; @@ -160,7 +161,8 @@ class Login extends BaseModule // if we haven't failed up this point, log them in. $_SESSION['remember'] = $remember; $_SESSION['last_login_date'] = DateTimeFormat::utcNow(); - Authentication::setAuthenticatedSessionForUser($record, true, true); + + Session::setAuthenticatedForUser($a, $record, true, true); if (!empty($_SESSION['return_path'])) { $return_path = $_SESSION['return_path']; @@ -210,7 +212,7 @@ class Login extends BaseModule // Do the authentification if not done by now if (!isset($_SESSION) || !isset($_SESSION['authenticated'])) { - Authentication::setAuthenticatedSessionForUser($user); + Session::setAuthenticatedForUser($a, $user); if (Config::get('system', 'paranoia')) { $_SESSION['addr'] = $data->ip; @@ -263,7 +265,8 @@ class Login extends BaseModule $_SESSION['last_login_date'] = DateTimeFormat::utcNow(); $login_refresh = true; } - Authentication::setAuthenticatedSessionForUser($user, false, false, $login_refresh); + + Session::setAuthenticatedForUser($a, $user, false, false, $login_refresh); } } } diff --git a/src/Module/Settings/TwoFactor/Index.php b/src/Module/Settings/TwoFactor/Index.php new file mode 100644 index 000000000..9f0269270 --- /dev/null +++ b/src/Module/Settings/TwoFactor/Index.php @@ -0,0 +1,108 @@ +generateSecretKey(32)); + + self::getApp()->internalRedirect('settings/2fa/recovery?t=' . self::getFormSecurityToken('settings_2fa_password')); + } + break; + case 'disable': + if ($has_secret) { + TwoFactorRecoveryCode::deleteForUser(local_user()); + PConfig::delete(local_user(), '2fa', 'secret'); + PConfig::delete(local_user(), '2fa', 'verified'); + Session::remove('2fa'); + + notice(L10n::t('Two-factor authentication successfully disabled.')); + self::getApp()->internalRedirect('settings/2fa'); + } + break; + case 'recovery': + if ($has_secret) { + self::getApp()->internalRedirect('settings/2fa/recovery?t=' . self::getFormSecurityToken('settings_2fa_password')); + } + break; + case 'configure': + if (!$verified) { + self::getApp()->internalRedirect('settings/2fa/verify?t=' . self::getFormSecurityToken('settings_2fa_password')); + } + break; + } + } catch (\Exception $e) { + notice(L10n::t('Wrong Password')); + } + } + + public static function content() + { + if (!local_user()) { + return Login::form('settings/2fa'); + } + + parent::content(); + + $has_secret = (bool) PConfig::get(local_user(), '2fa', 'secret'); + $verified = PConfig::get(local_user(), '2fa', 'verified'); + + return Renderer::replaceMacros(Renderer::getMarkupTemplate('settings/twofactor/index.tpl'), [ + '$form_security_token' => self::getFormSecurityToken('settings_2fa'), + '$title' => L10n::t('Two-factor authentication'), + '$help_label' => L10n::t('Help'), + '$status_title' => L10n::t('Status'), + '$message' => L10n::t('

    Use an application on a mobile device to get two-factor authentication codes when prompted on login.

    '), + '$has_secret' => $has_secret, + '$verified' => $verified, + + '$auth_app_label' => L10n::t('Authenticator app'), + '$app_status' => $has_secret ? $verified ? L10n::t('Configured') : L10n::t('Not Configured') : L10n::t('Disabled'), + '$not_configured_message' => L10n::t('

    You haven\'t finished configuring your authenticator app.

    '), + '$configured_message' => L10n::t('

    Your authenticator app is correctly configured.

    '), + + '$recovery_codes_title' => L10n::t('Recovery codes'), + '$recovery_codes_remaining' => L10n::t('Remaining valid codes'), + '$recovery_codes_count' => TwoFactorRecoveryCode::countValidForUser(local_user()), + '$recovery_codes_message' => L10n::t('

    These one-use codes can replace an authenticator app code in case you have lost access to it.

    '), + + '$action_title' => L10n::t('Actions'), + '$password' => ['password', L10n::t('Current password:'), '', L10n::t('You need to provide your current password to change two-factor authentication settings.'), 'required', 'autofocus'], + '$enable_label' => L10n::t('Enable two-factor authentication'), + '$disable_label' => L10n::t('Disable two-factor authentication'), + '$recovery_codes_label' => L10n::t('Show recovery codes'), + '$configure_label' => L10n::t('Finish app configuration'), + ]); + } +} diff --git a/src/Module/Settings/TwoFactor/Recovery.php b/src/Module/Settings/TwoFactor/Recovery.php new file mode 100644 index 000000000..a2d08cda8 --- /dev/null +++ b/src/Module/Settings/TwoFactor/Recovery.php @@ -0,0 +1,86 @@ +internalRedirect('settings/2fa'); + } + + if (!self::checkFormSecurityToken('settings_2fa_password', 't')) { + notice(L10n::t('Please enter your password to access this page.')); + self::getApp()->internalRedirect('settings/2fa'); + } + } + + public static function post() + { + if (!local_user()) { + return; + } + + if (!empty($_POST['action'])) { + self::checkFormSecurityTokenRedirectOnError('settings/2fa/recovery', 'settings_2fa_recovery'); + + if ($_POST['action'] == 'regenerate') { + TwoFactorRecoveryCode::regenerateForUser(local_user()); + notice(L10n::t('New recovery codes successfully generated.')); + self::getApp()->internalRedirect('settings/2fa/recovery?t=' . self::getFormSecurityToken('settings_2fa_password')); + } + } + } + + public static function content() + { + if (!local_user()) { + return Login::form('settings/2fa/recovery'); + } + + parent::content(); + + if (!TwoFactorRecoveryCode::countValidForUser(local_user())) { + TwoFactorRecoveryCode::generateForUser(local_user()); + } + + $recoveryCodes = TwoFactorRecoveryCode::getListForUser(local_user()); + + $verified = PConfig::get(local_user(), '2fa', 'verified'); + + return Renderer::replaceMacros(Renderer::getMarkupTemplate('settings/twofactor/recovery.tpl'), [ + '$form_security_token' => self::getFormSecurityToken('settings_2fa_recovery'), + '$password_security_token' => self::getFormSecurityToken('settings_2fa_password'), + + '$title' => L10n::t('Two-factor recovery codes'), + '$help_label' => L10n::t('Help'), + '$message' => L10n::t('

    Recovery codes can be used to access your account in the event you lose access to your device and cannot receive two-factor authentication codes.

    Put these in a safe spot! If you lose your device and don’t have the recovery codes you will lose access to your account.

    '), + '$recovery_codes' => $recoveryCodes, + '$regenerate_message' => L10n::t('When you generate new recovery codes, you must copy the new codes. Your old codes won’t work anymore.'), + '$regenerate_label' => L10n::t('Generate new recovery codes'), + '$verified' => $verified, + '$verify_label' => L10n::t('Next: Verification'), + ]); + } +} diff --git a/src/Module/Settings/TwoFactor/Verify.php b/src/Module/Settings/TwoFactor/Verify.php new file mode 100644 index 000000000..caed464ec --- /dev/null +++ b/src/Module/Settings/TwoFactor/Verify.php @@ -0,0 +1,130 @@ +internalRedirect('settings/2fa'); + } + + if (!self::checkFormSecurityToken('settings_2fa_password', 't')) { + notice(L10n::t('Please enter your password to access this page.')); + self::getApp()->internalRedirect('settings/2fa'); + } + } + + public static function post() + { + if (!local_user()) { + return; + } + + if (defaults($_POST, 'action', null) == 'verify') { + self::checkFormSecurityTokenRedirectOnError('settings/2fa/verify', 'settings_2fa_verify'); + + $google2fa = new Google2FA(); + + $valid = $google2fa->verifyKey(PConfig::get(local_user(), '2fa', 'secret'), defaults($_POST, 'verify_code', '')); + + if ($valid) { + PConfig::set(local_user(), '2fa', 'verified', true); + Session::set('2fa', true); + + notice(L10n::t('Two-factor authentication successfully activated.')); + + self::getApp()->internalRedirect('settings/2fa'); + } else { + notice(L10n::t('Invalid code, please retry.')); + } + } + } + + public static function content() + { + if (!local_user()) { + return Login::form('settings/2fa/verify'); + } + + parent::content(); + + $company = 'Friendica'; + $holder = Session::get('my_address'); + $secret = PConfig::get(local_user(), '2fa', 'secret'); + + $otpauthUrl = (new Google2FA())->getQRCodeUrl($company, $holder, $secret); + + $renderer = (new \BaconQrCode\Renderer\Image\Svg()) + ->setHeight(256) + ->setWidth(256); + + $writer = new Writer($renderer); + + $qrcode_image = str_replace('', '', $writer->writeString($otpauthUrl)); + + $shortOtpauthUrl = explode('?', $otpauthUrl)[0]; + + $manual_message = L10n::t('

    Or you can submit the authentication settings manually:

    +
    +
    Issuer
    +
    %s
    +
    Account Name
    +
    %s
    +
    Secret Key
    +
    %s
    +
    Type
    +
    Time-based
    +
    Number of digits
    +
    6
    +
    Hashing algorithm
    +
    SHA-1
    +
    ', $company, $holder, $secret); + + return Renderer::replaceMacros(Renderer::getMarkupTemplate('settings/twofactor/verify.tpl'), [ + '$form_security_token' => self::getFormSecurityToken('settings_2fa_verify'), + '$password_security_token' => self::getFormSecurityToken('settings_2fa_password'), + + '$title' => L10n::t('Two-factor code verification'), + '$help_label' => L10n::t('Help'), + '$message' => L10n::t('

    Please scan this QR Code with your authenticator app and submit the provided code.

    '), + '$qrcode_image' => $qrcode_image, + '$qrcode_url_message' => L10n::t('

    Or you can open the following URL in your mobile devicde:

    %s

    ', $otpauthUrl, $shortOtpauthUrl), + '$manual_message' => $manual_message, + '$company' => $company, + '$holder' => $holder, + '$secret' => $secret, + + '$verify_code' => ['verify_code', L10n::t('Please enter a code from your authentication app'), '', '', 'required', 'autofocus placeholder="000000"'], + '$verify_label' => L10n::t('Verify code and enable two-factor authentication'), + ]); + } +} diff --git a/src/Module/TwoFactor/Recovery.php b/src/Module/TwoFactor/Recovery.php new file mode 100644 index 000000000..4952d220b --- /dev/null +++ b/src/Module/TwoFactor/Recovery.php @@ -0,0 +1,72 @@ +user, true, true); + } else { + notice(L10n::t('Invalid code, please retry.')); + } + } + } + + public static function content() + { + if (!local_user()) { + self::getApp()->internalRedirect(); + } + + // Already authenticated with 2FA token + if (Session::get('2fa')) { + self::getApp()->internalRedirect(); + } + + return Renderer::replaceMacros(Renderer::getMarkupTemplate('twofactor/recovery.tpl'), [ + '$form_security_token' => self::getFormSecurityToken('twofactor_recovery'), + + '$title' => L10n::t('Two-factor recovery'), + '$message' => L10n::t('

    You can enter one of your one-time recovery codes in case you lost access to your mobile device.

    '), + '$recovery_message' => L10n::t('Don’t have your phone? Enter a two-factor recovery code', '2fa/recovery'), + '$recovery_code' => ['recovery_code', L10n::t('Please enter a recovery code'), '', '', '', 'placeholder="000000-000000"'], + '$recovery_label' => L10n::t('Submit recovery code and complete login'), + ]); + } +} diff --git a/src/Module/TwoFactor/Verify.php b/src/Module/TwoFactor/Verify.php new file mode 100644 index 000000000..abe6077c6 --- /dev/null +++ b/src/Module/TwoFactor/Verify.php @@ -0,0 +1,67 @@ +verifyKey(PConfig::get(local_user(), '2fa', 'secret'), $code); + + // The same code can't be used twice even if it's valid + if ($valid && Session::get('2fa') !== $code) { + Session::set('2fa', $code); + + // Resume normal login workflow + Session::setAuthenticatedForUser($a, $a->user, true, true); + } else { + notice(L10n::t('Invalid code, please retry.')); + } + } + } + + public static function content() + { + if (!local_user()) { + self::getApp()->internalRedirect(); + } + + // Already authenticated with 2FA token + if (Session::get('2fa')) { + self::getApp()->internalRedirect(); + } + + return Renderer::replaceMacros(Renderer::getMarkupTemplate('twofactor/verify.tpl'), [ + '$form_security_token' => self::getFormSecurityToken('twofactor_verify'), + + '$title' => L10n::t('Two-factor authentication'), + '$message' => L10n::t('

    Open the two-factor authentication app on your device to get an authentication code and verify your identity.

    '), + '$recovery_message' => L10n::t('Don’t have your phone? Enter a two-factor recovery code', '2fa/recovery'), + '$verify_code' => ['verify_code', L10n::t('Please enter a code from your authentication app'), '', '', 'required', 'autofocus placeholder="000000"'], + '$verify_label' => L10n::t('Verify code and complete login'), + ]); + } +} diff --git a/view/templates/settings/twofactor/index.tpl b/view/templates/settings/twofactor/index.tpl new file mode 100644 index 000000000..57bcab19e --- /dev/null +++ b/view/templates/settings/twofactor/index.tpl @@ -0,0 +1,39 @@ +
    +

    {{$title}}

    +
    {{$message nofilter}}
    +

    {{$status_title}}

    +

    {{$auth_app_label}}: {{$app_status}}

    +{{if $has_secret && $verified}} +
    {{$configured_message nofilter}}
    +{{/if}} +{{if $has_secret && !$verified}} +
    {{$not_configured_message nofilter}}
    +{{/if}} + +{{if $has_secret && $verified}} +

    {{$recovery_codes_title}}

    +

    {{$recovery_codes_remaining}}: {{$recovery_codes_count}}

    +
    {{$recovery_codes_message nofilter}}
    +{{/if}} + +
    +

    {{$action_title}}

    + + + {{include file="field_password.tpl" field=$password}} + +
    +{{if !$has_secret}} + +{{else}} + +{{/if}} +{{if $has_secret && $verified}} + +{{/if}} +{{if $has_secret && !$verified}} + +{{/if}} +
    +
    +
    diff --git a/view/templates/settings/twofactor/recovery.tpl b/view/templates/settings/twofactor/recovery.tpl new file mode 100644 index 000000000..8ecd0198d --- /dev/null +++ b/view/templates/settings/twofactor/recovery.tpl @@ -0,0 +1,28 @@ +
    +

    {{$title}}

    +
    {{$message nofilter}}
    + + + +{{if $verified}} +
    +

    {{$regenerate_label}}

    + +
    {{$regenerate_message}}
    + +
    + +
    +
    +{{else}} +

    {{$verify_label}}

    +{{/if}} +
    diff --git a/view/templates/settings/twofactor/verify.tpl b/view/templates/settings/twofactor/verify.tpl new file mode 100644 index 000000000..03138659e --- /dev/null +++ b/view/templates/settings/twofactor/verify.tpl @@ -0,0 +1,22 @@ +
    +

    {{$title}}

    +
    {{$message nofilter}}
    + +
    + {{$qrcode_image nofilter}} +
    + +
    + + + {{include file="field_input.tpl" field=$verify_code}} + +
    + +
    +
    + +
    {{$qrcode_url_message nofilter}}
    + +
    {{$manual_message nofilter}}
    +
    diff --git a/view/templates/twofactor/recovery.tpl b/view/templates/twofactor/recovery.tpl new file mode 100644 index 000000000..c32c8d2d8 --- /dev/null +++ b/view/templates/twofactor/recovery.tpl @@ -0,0 +1,14 @@ +
    +

    {{$title}}

    +
    {{$message nofilter}}
    + +
    + + + {{include file="field_input.tpl" field=$recovery_code}} + +
    + +
    +
    +
    diff --git a/view/templates/twofactor/verify.tpl b/view/templates/twofactor/verify.tpl new file mode 100644 index 000000000..d75d6291a --- /dev/null +++ b/view/templates/twofactor/verify.tpl @@ -0,0 +1,15 @@ +
    +

    {{$title}}

    +
    {{$message nofilter}}
    + +
    + + + {{include file="field_input.tpl" field=$verify_code}} + +
    + +
    +
    +
    {{$recovery_message nofilter}}
    +