Просмотр исходного кода

Add TOTP-based two-factor authentication

Colin O'Dell 5 лет назад
Родитель
Сommit
3e7ef23859

+ 2 - 0
app/AppKernel.php

@@ -24,6 +24,8 @@ class AppKernel extends Kernel
             new Nelmio\SecurityBundle\NelmioSecurityBundle(),
             new Knp\Bundle\MenuBundle\KnpMenuBundle(),
             new Nelmio\CorsBundle\NelmioCorsBundle(),
+            new Scheb\TwoFactorBundle\SchebTwoFactorBundle(),
+            new Endroid\QrCodeBundle\EndroidQrCodeBundle(),
             new Packagist\WebBundle\PackagistWebBundle(),
         );
 

+ 42 - 0
app/Resources/SchebTwoFactorBundle/views/Authentication/form.html.twig

@@ -0,0 +1,42 @@
+{% extends 'PackagistWebBundle::layout.html.twig' %}
+
+{% block content %}
+    <h2 class="title">Two-Factor Authentication</h2>
+
+    <p>This account is protected by two-factor authentication. Please enter your code below to proceed.</p>
+
+    <section class="row">
+        <div class="col-md-6">
+            {# Authentication errors #}
+            {% if authenticationError %}
+                <p class="alert alert-danger">{{ authenticationError|trans(authenticationErrorData, 'SchebTwoFactorBundle') }}</p>
+            {% endif %}
+
+            <form action="{{ path("2fa_login_check") }}" method="post">
+                <div class="form-group">
+                    <label for="_auth_code">Authentication code:</label>
+                    <div class="input-group">
+                        <input id="_auth_code" class="form-control" type="text" autocomplete="off" name="{{ authCodeParameterName }}" />
+                        <span class="input-group-addon"><span class="icon-key"></span></span>
+                    </div>
+                </div>
+
+                {% if displayTrustedOption %}
+                    <p class="widget"><label for="_trusted"><input id="_trusted" type="checkbox" name="{{ trustedParameterName }}" /> {{ "trusted"|trans({}, 'SchebTwoFactorBundle') }}</label></p>
+                {% endif %}
+
+                <input type="submit" class="btn btn-block btn-primary btn-lg" value="{{ "login"|trans({}, 'SchebTwoFactorBundle') }}" />
+
+                {% if isCsrfProtectionEnabled %}
+                    <input type="hidden" name="{{ csrfParameterName }}" value="{{ csrf_token(csrfTokenId) }}">
+                {% endif %}
+            </form>
+
+            <hr>
+
+            {# The logout link gives the user a way out if they can't complete two-factor authentication #}
+            <a href="{{ logoutPath }}">{{ "cancel"|trans({}, 'SchebTwoFactorBundle') }}</a>
+        </div>
+        <div class="clearfix"></div>
+    </section>
+{% endblock %}

+ 10 - 0
app/config/config.yml

@@ -135,6 +135,13 @@ hwi_oauth:
             options:
                 csrf: true
 
+scheb_two_factor:
+    totp:
+        enabled: true
+        server_name: '%packagist_host%'
+        issuer: Packagist
+        window: 1
+
 nelmio_cors:
     defaults:
         allow_origin: ['*']
@@ -162,3 +169,6 @@ httplug:
 
 sensio_framework_extra:
     router: { annotations: false }
+
+endroid_qr_code:
+    background_color: { r: 250, g: 250, b: 250 }

+ 8 - 0
app/config/routing.yml

@@ -44,3 +44,11 @@ logout:
 
 login_check:
     path: /login_check
+
+2fa_login:
+    path: /2fa
+    defaults:
+        _controller: "scheb_two_factor.form_controller:form"
+
+2fa_login_check:
+    path: /2fa-check

+ 8 - 0
app/config/security.yml

@@ -34,6 +34,10 @@ security:
                 failure_path:      /login
                 oauth_user_provider:
                     service: packagist.user_provider
+            two_factor:
+                auth_form_path: 2fa_login
+                check_path: 2fa_login_check
+                csrf_token_generator: security.csrf.token_manager
             switch_user:
                 provider: packagist
 
@@ -48,6 +52,10 @@ security:
         - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
+        # This makes the logout route available during two-factor authentication, allows the user to cancel
+        - { path: ^/logout, role: IS_AUTHENTICATED_ANONYMOUSLY }
+        # This ensures that the form can only be accessed when two-factor authentication is in progress
+        - { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
         # Secured part of the site
         # This config requires being logged for the whole site and having the admin role for the admin part.
         # Change these rules to adapt them to your needs

+ 2 - 0
composer.json

@@ -40,6 +40,8 @@
         "composer/composer": "^1.8@dev",
         "friendsofsymfony/user-bundle": "^2.0",
         "hwi/oauth-bundle": "^0.6",
+        "scheb/two-factor-bundle": "^4.7",
+        "endroid/qr-code-bundle": "^3.3",
         "nelmio/security-bundle": "^2.4",
         "predis/predis": "^1.0",
         "snc/redis-bundle": "^2.0",

+ 648 - 1
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": "bb9e56d2f9c92be63842385864ff2290",
+    "content-hash": "c4b8ac3ab71fbcec8f0e39cb340f36ab",
     "packages": [
         {
             "name": "algolia/algoliasearch-client-php",
@@ -74,6 +74,115 @@
             ],
             "time": "2019-02-26T15:58:17+00:00"
         },
+        {
+            "name": "bacon/bacon-qr-code",
+            "version": "2.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/Bacon/BaconQrCode.git",
+                "reference": "eaac909da3ccc32b748a65b127acd8918f58d9b0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/eaac909da3ccc32b748a65b127acd8918f58d9b0",
+                "reference": "eaac909da3ccc32b748a65b127acd8918f58d9b0",
+                "shasum": ""
+            },
+            "require": {
+                "dasprid/enum": "^1.0",
+                "ext-iconv": "*",
+                "php": "^7.1"
+            },
+            "require-dev": {
+                "phly/keep-a-changelog": "^1.4",
+                "phpunit/phpunit": "^6.4",
+                "squizlabs/php_codesniffer": "^3.1"
+            },
+            "suggest": {
+                "ext-imagick": "to generate QR code images"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "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": "2018-04-25T17:53:56+00:00"
+        },
+        {
+            "name": "beberlei/assert",
+            "version": "v3.2.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/beberlei/assert.git",
+                "reference": "65b8152224aef7d3c197d5db05211d4319711b66"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/beberlei/assert/zipball/65b8152224aef7d3c197d5db05211d4319711b66",
+                "reference": "65b8152224aef7d3c197d5db05211d4319711b66",
+                "shasum": ""
+            },
+            "require": {
+                "ext-ctype": "*",
+                "ext-intl": "*",
+                "ext-json": "*",
+                "ext-mbstring": "*",
+                "ext-simplexml": "*",
+                "php": "^7"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "*",
+                "phpstan/phpstan-shim": "*",
+                "phpunit/phpunit": ">=6.0.0 <8"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Assert\\": "lib/Assert"
+                },
+                "files": [
+                    "lib/Assert/functions.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-2-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Benjamin Eberlei",
+                    "email": "kontakt@beberlei.de",
+                    "role": "Lead Developer"
+                },
+                {
+                    "name": "Richard Quadling",
+                    "email": "rquadling@gmail.com",
+                    "role": "Collaborator"
+                }
+            ],
+            "description": "Thin assertion library for input validation in business models.",
+            "keywords": [
+                "assert",
+                "assertion",
+                "validation"
+            ],
+            "time": "2019-08-23T17:56:26+00:00"
+        },
         {
             "name": "cebe/markdown",
             "version": "1.2.1",
@@ -519,6 +628,48 @@
             "homepage": "https://github.com/container-interop/container-interop",
             "time": "2017-02-14T19:40:03+00:00"
         },
+        {
+            "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/annotations",
             "version": "v1.6.1",
@@ -1362,6 +1513,166 @@
             ],
             "time": "2018-12-04T22:38:24+00:00"
         },
+        {
+            "name": "endroid/installer",
+            "version": "1.1.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/endroid/installer.git",
+                "reference": "d4ae2bbd7977148dcb514ad625ece1a36f751a8b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/endroid/installer/zipball/d4ae2bbd7977148dcb514ad625ece1a36f751a8b",
+                "reference": "d4ae2bbd7977148dcb514ad625ece1a36f751a8b",
+                "shasum": ""
+            },
+            "require": {
+                "composer-plugin-api": "^1.1",
+                "php": ">=7.2"
+            },
+            "type": "composer-plugin",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                },
+                "class": "Endroid\\Installer\\Installer"
+            },
+            "autoload": {
+                "psr-4": {
+                    "Endroid\\Installer\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jeroen van den Enden",
+                    "email": "info@endroid.nl"
+                }
+            ],
+            "time": "2019-03-30T21:42:01+00:00"
+        },
+        {
+            "name": "endroid/qr-code",
+            "version": "3.6.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/endroid/qr-code.git",
+                "reference": "15641eec67291c6404b612694f65345c84a79919"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/endroid/qr-code/zipball/15641eec67291c6404b612694f65345c84a79919",
+                "reference": "15641eec67291c6404b612694f65345c84a79919",
+                "shasum": ""
+            },
+            "require": {
+                "bacon/bacon-qr-code": "^2.0",
+                "endroid/installer": "^1.1.5",
+                "ext-gd": "*",
+                "khanamiryan/qrcode-detector-decoder": "^1.0.2",
+                "myclabs/php-enum": "^1.5",
+                "php": ">=7.2",
+                "symfony/http-foundation": "^3.4|^4.0",
+                "symfony/options-resolver": "^3.4|^4.0",
+                "symfony/property-access": "^3.4|^4.0"
+            },
+            "require-dev": {
+                "endroid/test": "^1.1.4"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Endroid\\QrCode\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jeroen van den Enden",
+                    "email": "info@endroid.nl"
+                }
+            ],
+            "description": "Endroid QR Code",
+            "homepage": "https://github.com/endroid/qr-code",
+            "keywords": [
+                "bundle",
+                "code",
+                "endroid",
+                "php",
+                "qr",
+                "qrcode"
+            ],
+            "time": "2019-05-25T20:13:40+00:00"
+        },
+        {
+            "name": "endroid/qr-code-bundle",
+            "version": "3.3.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/endroid/qr-code-bundle.git",
+                "reference": "975dc3d377aba75ddc9ada47315b4edd9c0687ba"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/endroid/qr-code-bundle/zipball/975dc3d377aba75ddc9ada47315b4edd9c0687ba",
+                "reference": "975dc3d377aba75ddc9ada47315b4edd9c0687ba",
+                "shasum": ""
+            },
+            "require": {
+                "endroid/qr-code": "^3.4.4",
+                "php": ">=7.1",
+                "symfony/framework-bundle": "^3.4|^4.0",
+                "symfony/twig-bundle": "^3.4|^4.0",
+                "symfony/yaml": "^3.4|^4.0"
+            },
+            "require-dev": {
+                "endroid/bundle-test": "dev-master"
+            },
+            "type": "symfony-bundle",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Endroid\\QrCodeBundle\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jeroen van den Enden",
+                    "email": "info@endroid.nl"
+                }
+            ],
+            "description": "Endroid QR Code Bundle",
+            "homepage": "https://github.com/endroid/qr-code-bundle",
+            "keywords": [
+                "bundle",
+                "code",
+                "endroid",
+                "php",
+                "qr",
+                "symfony"
+            ],
+            "time": "2019-04-04T06:55:45+00:00"
+        },
         {
             "name": "ezyang/htmlpurifier",
             "version": "v4.10.0",
@@ -2053,6 +2364,56 @@
             ],
             "time": "2019-01-14T23:55:14+00:00"
         },
+        {
+            "name": "khanamiryan/qrcode-detector-decoder",
+            "version": "1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/khanamiryan/php-qrcode-detector-decoder.git",
+                "reference": "a75482d3bc804e3f6702332bfda6cccbb0dfaa76"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/khanamiryan/php-qrcode-detector-decoder/zipball/a75482d3bc804e3f6702332bfda6cccbb0dfaa76",
+                "reference": "a75482d3bc804e3f6702332bfda6cccbb0dfaa76",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.6|^7.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^5.7"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Zxing\\": "lib/"
+                },
+                "files": [
+                    "lib/Common/customFunctions.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ashot Khanamiryan",
+                    "email": "a.khanamiryan@gmail.com",
+                    "homepage": "https://github.com/khanamiryan",
+                    "role": "Developer"
+                }
+            ],
+            "description": "QR code decoder / reader",
+            "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder/",
+            "keywords": [
+                "barcode",
+                "qr",
+                "zxing"
+            ],
+            "time": "2018-04-26T11:41:33+00:00"
+        },
         {
             "name": "knplabs/knp-menu",
             "version": "2.3.0",
@@ -2176,6 +2537,61 @@
             ],
             "time": "2017-12-24T16:32:39+00:00"
         },
+        {
+            "name": "lcobucci/jwt",
+            "version": "3.3.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/lcobucci/jwt.git",
+                "reference": "a11ec5f4b4d75d1fcd04e133dede4c317aac9e18"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/lcobucci/jwt/zipball/a11ec5f4b4d75d1fcd04e133dede4c317aac9e18",
+                "reference": "a11ec5f4b4d75d1fcd04e133dede4c317aac9e18",
+                "shasum": ""
+            },
+            "require": {
+                "ext-mbstring": "*",
+                "ext-openssl": "*",
+                "php": "^5.6 || ^7.0"
+            },
+            "require-dev": {
+                "mikey179/vfsstream": "~1.5",
+                "phpmd/phpmd": "~2.2",
+                "phpunit/php-invoker": "~1.1",
+                "phpunit/phpunit": "^5.7 || ^7.3",
+                "squizlabs/php_codesniffer": "~2.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.1-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Lcobucci\\JWT\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Luís Otávio Cobucci Oblonczyk",
+                    "email": "lcobucci@gmail.com",
+                    "role": "Developer"
+                }
+            ],
+            "description": "A simple library to work with JSON Web Token and JSON Web Signature",
+            "keywords": [
+                "JWS",
+                "jwt"
+            ],
+            "time": "2019-05-24T18:30:49+00:00"
+        },
         {
             "name": "monolog/monolog",
             "version": "1.24.0",
@@ -2254,6 +2670,51 @@
             ],
             "time": "2018-11-05T09:00:11+00:00"
         },
+        {
+            "name": "myclabs/php-enum",
+            "version": "1.7.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/myclabs/php-enum.git",
+                "reference": "45f01adf6922df6082bcda36619deb466e826acf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/myclabs/php-enum/zipball/45f01adf6922df6082bcda36619deb466e826acf",
+                "reference": "45f01adf6922df6082bcda36619deb466e826acf",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "php": ">=7.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8.35|^5.7|^6.0",
+                "squizlabs/php_codesniffer": "1.*"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "MyCLabs\\Enum\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP Enum contributors",
+                    "homepage": "https://github.com/myclabs/php-enum/graphs/contributors"
+                }
+            ],
+            "description": "PHP Enum implementation",
+            "homepage": "http://github.com/myclabs/php-enum",
+            "keywords": [
+                "enum"
+            ],
+            "time": "2019-08-19T13:53:00+00:00"
+        },
         {
             "name": "nelmio/cors-bundle",
             "version": "1.5.5",
@@ -2442,6 +2903,68 @@
             ],
             "time": "2019-04-02T08:50:39+00:00"
         },
+        {
+            "name": "paragonie/constant_time_encoding",
+            "version": "v2.2.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/paragonie/constant_time_encoding.git",
+                "reference": "55af0dc01992b4d0da7f6372e2eac097bbbaffdb"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/55af0dc01992b4d0da7f6372e2eac097bbbaffdb",
+                "reference": "55af0dc01992b4d0da7f6372e2eac097bbbaffdb",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^6|^7",
+                "vimeo/psalm": "^1|^2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "ParagonIE\\ConstantTime\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Paragon Initiative Enterprises",
+                    "email": "security@paragonie.com",
+                    "homepage": "https://paragonie.com",
+                    "role": "Maintainer"
+                },
+                {
+                    "name": "Steve 'Sc00bz' Thomas",
+                    "email": "steve@tobtu.com",
+                    "homepage": "https://www.tobtu.com",
+                    "role": "Original Developer"
+                }
+            ],
+            "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
+            "keywords": [
+                "base16",
+                "base32",
+                "base32_decode",
+                "base32_encode",
+                "base64",
+                "base64_decode",
+                "base64_encode",
+                "bin2hex",
+                "encoding",
+                "hex",
+                "hex2bin",
+                "rfc4648"
+            ],
+            "time": "2019-01-03T20:26:31+00:00"
+        },
         {
             "name": "paragonie/random_compat",
             "version": "v2.0.18",
@@ -3492,6 +4015,69 @@
             "description": "A polyfill for getallheaders.",
             "time": "2016-02-11T07:05:27+00:00"
         },
+        {
+            "name": "scheb/two-factor-bundle",
+            "version": "v4.7.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/scheb/two-factor-bundle.git",
+                "reference": "170895e91bdbe2c21983f195271d42e2fcfb3d62"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/scheb/two-factor-bundle/zipball/170895e91bdbe2c21983f195271d42e2fcfb3d62",
+                "reference": "170895e91bdbe2c21983f195271d42e2fcfb3d62",
+                "shasum": ""
+            },
+            "require": {
+                "lcobucci/jwt": "^3.2",
+                "paragonie/constant_time_encoding": "^2.2",
+                "php": "^7.1.3",
+                "spomky-labs/otphp": "^9.1",
+                "symfony/config": "^3.4|^4.0",
+                "symfony/dependency-injection": "^3.4|^4.0",
+                "symfony/event-dispatcher": "^3.4|^4.0",
+                "symfony/framework-bundle": "^3.4|^4.0",
+                "symfony/http-foundation": "^3.4|^4.0",
+                "symfony/http-kernel": "^3.4|^4.0",
+                "symfony/property-access": "^3.4|^4.0",
+                "symfony/security-bundle": "^3.4|^4.0",
+                "symfony/twig-bundle": "^3.4|^4.0"
+            },
+            "require-dev": {
+                "doctrine/lexer": "^1.0.1",
+                "doctrine/orm": "^2.6",
+                "phpunit/phpunit": "^7.0|^8.0",
+                "swiftmailer/swiftmailer": "^6.0",
+                "symfony/yaml": "^3.4|^4.0"
+            },
+            "type": "symfony-bundle",
+            "autoload": {
+                "psr-4": {
+                    "Scheb\\TwoFactorBundle\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Scheb",
+                    "email": "me@christianscheb.de"
+                }
+            ],
+            "description": "Provides two-factor authentication for Symfony applications",
+            "homepage": "https://github.com/scheb/two-factor-bundle",
+            "keywords": [
+                "Authentication",
+                "security",
+                "symfony",
+                "two-factor",
+                "two-step"
+            ],
+            "time": "2019-09-02T18:36:37+00:00"
+        },
         {
             "name": "seld/jsonlint",
             "version": "1.7.1",
@@ -3922,6 +4508,67 @@
             ],
             "time": "2019-02-20T07:03:43+00:00"
         },
+        {
+            "name": "spomky-labs/otphp",
+            "version": "v9.1.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/Spomky-Labs/otphp.git",
+                "reference": "48d463cf909320399fe08eab2e1cd18d899d5068"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/48d463cf909320399fe08eab2e1cd18d899d5068",
+                "reference": "48d463cf909320399fe08eab2e1cd18d899d5068",
+                "shasum": ""
+            },
+            "require": {
+                "beberlei/assert": "^2.4|^3.0",
+                "paragonie/constant_time_encoding": "^2.0",
+                "php": "^7.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^6.0",
+                "satooshi/php-coveralls": "^1.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "9.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "OTPHP\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Florent Morselli",
+                    "homepage": "https://github.com/Spomky"
+                },
+                {
+                    "name": "All contributors",
+                    "homepage": "https://github.com/Spomky-Labs/otphp/contributors"
+                }
+            ],
+            "description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator",
+            "homepage": "https://github.com/Spomky-Labs/otphp",
+            "keywords": [
+                "FreeOTP",
+                "RFC 4226",
+                "RFC 6238",
+                "google authenticator",
+                "hotp",
+                "otp",
+                "totp"
+            ],
+            "time": "2019-03-18T10:08:51+00:00"
+        },
         {
             "name": "swiftmailer/swiftmailer",
             "version": "v6.2.0",

+ 81 - 0
src/Packagist/WebBundle/Controller/UserController.php

@@ -19,11 +19,15 @@ use Packagist\WebBundle\Entity\Package;
 use Packagist\WebBundle\Entity\Version;
 use Packagist\WebBundle\Entity\User;
 use Packagist\WebBundle\Entity\VersionRepository;
+use Packagist\WebBundle\Form\Model\EnableTwoFactorRequest;
+use Packagist\WebBundle\Form\Type\EnableTwoFactorAuthType;
 use Packagist\WebBundle\Model\RedisAdapter;
+use Packagist\WebBundle\Security\TwoFactorAuthManager;
 use Pagerfanta\Adapter\DoctrineORMAdapter;
 use Pagerfanta\Pagerfanta;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
+use Symfony\Component\Form\FormError;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -270,6 +274,83 @@ class UserController extends Controller
         return new Response('{"status": "success"}', 204);
     }
 
+    /**
+     * @Template()
+     * @Route("/users/{name}/2fa/", name="user_2fa_configure", methods={"GET"})
+     * @ParamConverter("user", options={"mapping": {"name": "username"}})
+     */
+    public function configureTwoFactorAuthAction(User $user)
+    {
+        if ($user->getId() !== $this->getUser()->getId()) {
+            throw new AccessDeniedException('You cannot change this user\'s two-factor authentication settings');
+        }
+
+        return array('user' => $user);
+    }
+
+    /**
+     * @Template()
+     * @Route("/users/{name}/2fa/enable", name="user_2fa_enable", methods={"GET", "POST"})
+     * @ParamConverter("user", options={"mapping": {"name": "username"}})
+     */
+    public function enableTwoFactorAuthAction(Request $req, User $user)
+    {
+        if ($user->getId() !== $this->getUser()->getId()) {
+            throw new AccessDeniedException('You cannot change this user\'s two-factor authentication settings');
+        }
+
+        $authenticator = $this->get("scheb_two_factor.security.totp_authenticator");
+
+        $enableRequest = new EnableTwoFactorRequest($authenticator->generateSecret());
+        $form = $this->createForm(EnableTwoFactorAuthType::class, $enableRequest);
+        $form->handleRequest($req);
+
+        // Temporarily store this code on the user, as we'll need it there to generate the
+        // QR code and to check the confirmation code.  We won't actually save this change
+        // until we've confirmed the code
+        $user->setTotpSecret($enableRequest->getSecret());
+
+        if ($form->isSubmitted()) {
+            // Validate the code using the secret that was submitted in the form
+            if (!$authenticator->checkCode($user, $enableRequest->getCode())) {
+                $form->get('code')->addError(new FormError('Invalid authenticator code'));
+            }
+
+            if ($form->isValid()) {
+                $this->get(TwoFactorAuthManager::class)->enableTwoFactorAuth($user, $enableRequest->getSecret());
+
+                $this->addFlash('success', 'Two-factor authentication has been enabled.');
+
+                return $this->redirectToRoute('user_2fa_configure', array('name' => $user->getUsername()));
+            }
+        }
+
+        return array('user' => $user, 'provisioningUri' => $authenticator->getQRContent($user), 'form' => $form->createView());
+    }
+
+    /**
+     * @Template()
+     * @Route("/users/{name}/2fa/disable", name="user_2fa_disable", methods={"GET"})
+     * @ParamConverter("user", options={"mapping": {"name": "username"}})
+     */
+    public function disableTwoFactorAuthAction(Request $req, User $user)
+    {
+        if ($user->getId() !== $this->getUser()->getId()) {
+            throw new AccessDeniedException('You cannot change this user\'s two-factor authentication settings');
+        }
+
+        $token = $this->get('security.csrf.token_manager')->getToken('disable_2fa')->getValue();
+        if (hash_equals($token, $req->query->get('token', ''))) {
+            $this->get(TwoFactorAuthManager::class)->disableTwoFactorAuth($user);
+
+            $this->addFlash('success', 'Two-factor authentication has been disabled.');
+
+            return $this->redirectToRoute('user_2fa_configure', array('name' => $user->getUsername()));
+        }
+
+        return array('user' => $user);
+    }
+
     /**
      * @param Request $req
      * @param User $user

+ 30 - 1
src/Packagist/WebBundle/Entity/User.php

@@ -14,6 +14,9 @@ namespace Packagist\WebBundle\Entity;
 
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\ORM\Mapping as ORM;
+use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface;
+use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface;
+use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration;
 use Symfony\Component\Validator\Constraints as Assert;
 use FOS\UserBundle\Model\User as BaseUser;
 
@@ -53,7 +56,7 @@ use FOS\UserBundle\Model\User as BaseUser;
  *     )
  * })
  */
-class User extends BaseUser
+class User extends BaseUser implements TwoFactorInterface
 {
     /**
      * @ORM\Id
@@ -120,6 +123,12 @@ class User extends BaseUser
      */
     private $failureNotifications = true;
 
+    /**
+     * @ORM\Column(name="totpSecret", type="string", nullable=true)
+     * @var string|null
+     */
+    private $totpSecret;
+
     public function __construct()
     {
         $this->packages = new ArrayCollection();
@@ -318,4 +327,24 @@ class User extends BaseUser
     {
         return 'https://www.gravatar.com/avatar/'.md5(strtolower($this->getEmail())).'?d=identicon';
     }
+
+    public function setTotpSecret(?string $secret)
+    {
+        $this->totpSecret = $secret;
+    }
+
+    public function isTotpAuthenticationEnabled(): bool
+    {
+        return $this->totpSecret ? true : false;
+    }
+
+    public function getTotpAuthenticationUsername(): string
+    {
+        return $this->username;
+    }
+
+    public function getTotpAuthenticationConfiguration(): TotpConfigurationInterface
+    {
+        return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
+    }
 }

+ 45 - 0
src/Packagist/WebBundle/Form/Model/EnableTwoFactorRequest.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Packagist\WebBundle\Form\Model;
+
+use Symfony\Component\Validator\Constraints as Assert;
+
+class EnableTwoFactorRequest
+{
+    /**
+     * @var string
+     * @Assert\NotBlank
+     */
+    protected $secret;
+
+    /**
+     * @var ?string
+     * @Assert\NotBlank
+     */
+    protected $code;
+
+    public function __construct(string $secret)
+    {
+        $this->secret = $secret;
+    }
+
+    public function getSecret(): string
+    {
+        return $this->secret;
+    }
+
+    public function setSecret(string $secret): void
+    {
+        $this->secret = $secret;
+    }
+
+    public function getCode(): ?string
+    {
+        return $this->code;
+    }
+
+    public function setCode(string $code): void
+    {
+        $this->code = $code;
+    }
+}

+ 39 - 0
src/Packagist/WebBundle/Form/Type/EnableTwoFactorAuthType.php

@@ -0,0 +1,39 @@
+<?php
+
+/*
+ * This file is part of Packagist.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *     Nils Adermann <naderman@naderman.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Packagist\WebBundle\Form\Type;
+
+use Packagist\WebBundle\Form\Model\EnableTwoFactorRequest;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\HiddenType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * @author Igor Wiedler <igor@wiedler.ch>
+ */
+class EnableTwoFactorAuthType extends AbstractType
+{
+    public function buildForm(FormBuilderInterface $builder, array $options)
+    {
+        $builder->add('secret', HiddenType::class);
+        $builder->add('code', TextType::class);
+    }
+
+    public function configureOptions(OptionsResolver $resolver)
+    {
+        $resolver->setDefaults(array(
+            'data_class' => EnableTwoFactorRequest::class,
+        ));
+    }
+}

+ 1 - 0
src/Packagist/WebBundle/Menu/MenuBuilder.php

@@ -53,6 +53,7 @@ class MenuBuilder
         $menu->addChild($this->translator->trans('menu.profile'), array('label' => '<span class="icon-vcard"></span>' . $this->translator->trans('menu.profile'), 'route' => 'fos_user_profile_show', 'extras' => array('safe_label' => true)));
         $menu->addChild($this->translator->trans('menu.settings'), array('label' => '<span class="icon-tools"></span>' . $this->translator->trans('menu.settings'), 'route' => 'fos_user_profile_edit', 'extras' => array('safe_label' => true)));
         $menu->addChild($this->translator->trans('menu.change_password'), array('label' => '<span class="icon-key"></span>' . $this->translator->trans('menu.change_password'), 'route' => 'fos_user_change_password', 'extras' => array('safe_label' => true)));
+        $menu->addChild($this->translator->trans('menu.configure_2fa'), array('label' => '<span class="icon-mobile"></span>' . $this->translator->trans('menu.configure_2fa'), 'route' => 'user_2fa_configure', 'routeParameters' => array('name' => $this->username), 'extras' => array('safe_label' => true)));
         $menu->addChild($this->translator->trans('menu.my_packages'), array('label' => '<span class="icon-box"></span>' . $this->translator->trans('menu.my_packages'), 'route' => 'user_packages', 'routeParameters' => array('name' => $this->username), 'extras' => array('safe_label' => true)));
         $menu->addChild($this->translator->trans('menu.my_favorites'), array('label' => '<span class="icon-leaf"></span>' . $this->translator->trans('menu.my_favorites'), 'route' => 'user_favorites', 'routeParameters' => array('name' => $this->username), 'extras' => array('safe_label' => true)));
     }

+ 10 - 0
src/Packagist/WebBundle/Resources/config/services.yml

@@ -206,6 +206,16 @@ services:
             - 'package:updates': '@updater_worker'
               'githubuser:migrate': '@github_user_migration_worker'
 
+    Packagist\WebBundle\Security\TwoFactorAuthManager:
+        public: true
+        class: Packagist\WebBundle\Security\TwoFactorAuthManager
+        arguments:
+            - '@doctrine'
+            - '@mailer'
+            - '@twig'
+            - '@logger'
+            - { from: '%mailer_from_email%', fromName: '%mailer_from_name%' }
+
     scheduler:
         public: true
         class: Packagist\WebBundle\Service\Scheduler

+ 1 - 0
src/Packagist/WebBundle/Resources/translations/messages.en.yml

@@ -21,6 +21,7 @@ menu:
     sign_in: Sign in
     settings: Settings
     change_password: Change password
+    configure_2fa: Two-factor auth
     my_packages: My packages
     my_favorites: My favorites
 

+ 7 - 0
src/Packagist/WebBundle/Resources/views/email/two_factor_disabled.txt.twig

@@ -0,0 +1,7 @@
+{% autoescape false -%}
+
+Two-factor authentication has been disabled on your Packagist account.
+
+You can re-enable this at any time from your account page: {{ url('user_2fa_configure', { 'name': username }) }}
+
+{%- endautoescape %}

+ 7 - 0
src/Packagist/WebBundle/Resources/views/email/two_factor_enabled.txt.twig

@@ -0,0 +1,7 @@
+{% autoescape false -%}
+
+Two-factor authentication has been enabled on your Packagist account.
+
+You can disable this at any time from your account page: {{ url('user_2fa_configure', { 'name': username }) }}
+
+{%- endautoescape %}

+ 39 - 0
src/Packagist/WebBundle/Resources/views/user/configure_two_factor_auth.html.twig

@@ -0,0 +1,39 @@
+{% extends "PackagistWebBundle:user:packages.html.twig" %}
+
+{% import "PackagistWebBundle::macros.html.twig" as macros %}
+
+{% set isActualUser = app.user and app.user.username is same as(user.username) %}
+
+{% block content %}
+    <h2 class="title">
+        {{ user.username }}
+    </h2>
+
+    <section class="row">
+        <section class="col-md-3">
+            {{ knp_menu_render('profile_menu', {currentClass: 'active', allow_safe_labels: true}) }}
+        </section>
+
+        <section class="col-md-9">
+            {% if user.totpAuthenticationEnabled %}
+                <p class="alert alert-success">
+                    <span class="icon-lock"></span>
+                    Two-factor authentication is currently <strong>enabled</strong>.
+                </p>
+
+                <p>Two-factor authentication is an extra layer of security for your Packagist account designed to ensure that you're the only person who can access your account, even if someone knows your password.</p>
+
+                <a href="{{ path('user_2fa_disable', {name: user.username}) }}" class="btn btn-block btn-danger">Disable two-factor authentication</a>
+            {% else %}
+                <p class="alert alert-warning">
+                    <span class="icon-unlock"></span>
+                    Two-factor authentication is currently <strong>disabled</strong>.
+                </p>
+
+                <p>Two-factor authentication is an extra layer of security for your Packagist account designed to ensure that you're the only person who can access your account, even if someone knows your password.</p>
+
+                <a href="{{ path('user_2fa_enable', {name: user.username}) }}" class="btn btn-block btn-primary btn-lg">Enable two-factor authentication</a>
+            {% endif %}
+        </section>
+    </section>
+{% endblock %}

+ 39 - 0
src/Packagist/WebBundle/Resources/views/user/disable_two_factor_auth.html.twig

@@ -0,0 +1,39 @@
+{% extends "PackagistWebBundle:user:packages.html.twig" %}
+
+{% import "PackagistWebBundle::macros.html.twig" as macros %}
+
+{% set isActualUser = app.user and app.user.username is same as(user.username) %}
+
+{% block content %}
+    <h2 class="title">
+        {{ user.username }}
+    </h2>
+
+    <section class="row">
+        <section class="col-md-3">
+            {{ knp_menu_render('profile_menu', {currentClass: 'active', allow_safe_labels: true}) }}
+        </section>
+
+        <section class="col-md-9">
+            <h3 class="text-danger">Are you sure you wish to disable two-factor authentication?</h3>
+
+            <p>Your account is more secure when you need a password and a verification code to sign in. If you remove this extra layer of security, you will only be asked for a password when you sign in. It might be easier for someone to break into your account.</p>
+
+            <p>If you'd like to proceed anyway, simply click the button below.</p>
+
+            <div class="row">
+                <div class="col-md-6">
+                    <a class="btn btn-lg btn-inverse" href="{{ path('user_2fa_configure', {name: user.username}) }}">Nevermind, keep my account secure</a>
+                </div>
+                <div class="col-md-6">
+                    <a href="{{ path('user_2fa_disable', {token: csrf_token('disable_2fa'), name: user.username}) }}" class="btn btn-block btn-danger btn-lg">
+                        <span class="icon-lock-open"></span>
+                        Disable two-factor authentication
+                    </a>
+                </div>
+            </div>
+
+
+        </section>
+    </section>
+{% endblock %}

+ 56 - 0
src/Packagist/WebBundle/Resources/views/user/enable_two_factor_auth.html.twig

@@ -0,0 +1,56 @@
+{% extends "PackagistWebBundle:user:packages.html.twig" %}
+
+{% import "PackagistWebBundle::macros.html.twig" as macros %}
+
+{% set isActualUser = app.user and app.user.username is same as(user.username) %}
+
+{% block content %}
+    <h2 class="title">
+        {{ user.username }}
+    </h2>
+
+    <section class="row">
+        <section class="col-md-3">
+            {{ knp_menu_render('profile_menu', {currentClass: 'active', allow_safe_labels: true}) }}
+        </section>
+
+        <section class="col-md-9">
+            <p>
+                To enable two-factor authentication, you'll need a mobile app that supports TOTP such as
+                <a href="https://authy.com/download/">Authy</a>
+                or
+                <a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a>.
+            </p>
+
+            <p><small>(This is not an exhaustive list of compatible apps. Packagist does not endorse or recommend one application over another.)</small></p>
+
+            <img src="{{ qr_code_data_uri(provisioningUri|raw) }}" height="200" class="pull-right" />
+
+            <h3>Enabling Two-Factor Authentication</h3>
+
+            <p>To enable two-factor authentication on your Packagist account:</p>
+
+            <ol>
+                <li>Open your authenticator app.</li>
+                <li>Add an account within the app and scan the QR core shown here.</li>
+                <li>Enter the code generated by your authenticator app below.</li>
+            </ol>
+
+            {{ form_start(form, {'attr': { 'class': 'col-md-6' } }) }}
+                {{ form_errors(form) }}
+
+                <div class="form-group clearfix">
+                    {{ form_label(form.code, 'Code shown in authenticator:') }}
+                    <div class="input-group">
+                        {{ form_errors(form.code) }}
+                        {{ form_widget(form.code) }}
+                        <span class="input-group-addon"><span class="icon-key"></span></span>
+                    </div>
+                </div>
+
+                <input type="submit" class="btn btn-block btn-inverse btn-lg" value="Enable Two-Factor Authentication" />
+            {{ form_end(form) }}
+        </section>
+        </section>
+    </section>
+{% endblock %}

+ 96 - 0
src/Packagist/WebBundle/Security/TwoFactorAuthManager.php

@@ -0,0 +1,96 @@
+<?php
+
+/*
+ * This file is part of Packagist.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *     Nils Adermann <naderman@naderman.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Packagist\WebBundle\Security;
+
+use Packagist\WebBundle\Entity\User;
+use Psr\Log\LoggerInterface;
+use Symfony\Bridge\Doctrine\RegistryInterface;
+use Twig\Environment;
+
+/**
+ * @author Colin O'Dell <colinodell@gmail.com>
+ */
+class TwoFactorAuthManager
+{
+    protected $doctrine;
+    protected $mailer;
+    protected $twig;
+    protected $logger;
+    protected $options;
+
+    public function __construct(RegistryInterface $doctrine, \Swift_Mailer $mailer, Environment $twig, LoggerInterface $logger, array $options)
+    {
+        $this->doctrine = $doctrine;
+        $this->mailer = $mailer;
+        $this->twig = $twig;
+        $this->logger = $logger;
+        $this->options = $options;
+    }
+
+    /**
+     * Enable two-factor auth on the given user account and send confirmation email.
+     *
+     * @param User   $user
+     * @param string $secret
+     */
+    public function enableTwoFactorAuth(User $user, string $secret)
+    {
+        $user->setTotpSecret($secret);
+        $this->doctrine->getManager()->flush();
+
+        $body = $this->twig->render('PackagistWebBundle:email:two_factor_enabled.txt.twig', array(
+            'username' => $user->getUsername(),
+        ));
+
+        $message = (new \Swift_Message)
+            ->setSubject('[Packagist] Two-factor authentication enabled')
+            ->setFrom($this->options['from'], $this->options['fromName'])
+            ->setTo($user->getEmail())
+            ->setBody($body)
+        ;
+
+        try {
+            $this->mailer->send($message);
+        } catch (\Swift_TransportException $e) {
+            $this->logger->error('['.get_class($e).'] '.$e->getMessage());
+        }
+    }
+
+    /**
+     * Disable two-factor auth on the given user account and send confirmation email.
+     *
+     * @param User   $user
+     */
+    public function disableTwoFactorAuth(User $user)
+    {
+        $user->setTotpSecret(null);
+        $this->doctrine->getManager()->flush();
+
+        $body = $this->twig->render('PackagistWebBundle:email:two_factor_disabled.txt.twig', array(
+            'username' => $user->getUsername(),
+        ));
+
+        $message = (new \Swift_Message)
+            ->setSubject('[Packagist] Two-factor authentication disabled')
+            ->setFrom($this->options['from'], $this->options['fromName'])
+            ->setTo($user->getEmail())
+            ->setBody($body)
+        ;
+
+        try {
+            $this->mailer->send($message);
+        } catch (\Swift_TransportException $e) {
+            $this->logger->error('['.get_class($e).'] '.$e->getMessage());
+        }
+    }
+}