Forráskód Böngészése

Merge remote-tracking branch 'colinodell/feature/2fa'

Jordi Boggiano 5 éve
szülő
commit
4aa3b70387
28 módosított fájl, 1612 hozzáadás és 194 törlés
  1. 2 0
      app/AppKernel.php
  2. 42 0
      app/Resources/SchebTwoFactorBundle/views/Authentication/form.html.twig
  3. 22 0
      app/config/config.yml
  4. 8 0
      app/config/routing.yml
  5. 10 1
      app/config/security.yml
  6. 2 0
      composer.json
  7. 528 57
      composer.lock
  8. 108 0
      src/Packagist/WebBundle/Controller/UserController.php
  9. 57 1
      src/Packagist/WebBundle/Entity/User.php
  10. 45 0
      src/Packagist/WebBundle/Form/Model/EnableTwoFactorRequest.php
  11. 39 0
      src/Packagist/WebBundle/Form/Type/EnableTwoFactorAuthType.php
  12. 1 0
      src/Packagist/WebBundle/Menu/MenuBuilder.php
  13. 18 0
      src/Packagist/WebBundle/Resources/config/services.yml
  14. 11 0
      src/Packagist/WebBundle/Resources/public/css/main.css
  15. 184 0
      src/Packagist/WebBundle/Resources/public/font/config.json
  16. BIN
      src/Packagist/WebBundle/Resources/public/font/fontello.eot
  17. 65 135
      src/Packagist/WebBundle/Resources/public/font/fontello.svg
  18. BIN
      src/Packagist/WebBundle/Resources/public/font/fontello.ttf
  19. BIN
      src/Packagist/WebBundle/Resources/public/font/fontello.woff
  20. 1 0
      src/Packagist/WebBundle/Resources/translations/messages.en.yml
  21. 12 0
      src/Packagist/WebBundle/Resources/views/email/two_factor_disabled.txt.twig
  22. 7 0
      src/Packagist/WebBundle/Resources/views/email/two_factor_enabled.txt.twig
  23. 56 0
      src/Packagist/WebBundle/Resources/views/user/configure_two_factor_auth.html.twig
  24. 53 0
      src/Packagist/WebBundle/Resources/views/user/confirm_two_factor_auth.html.twig
  25. 49 0
      src/Packagist/WebBundle/Resources/views/user/disable_two_factor_auth.html.twig
  26. 71 0
      src/Packagist/WebBundle/Resources/views/user/enable_two_factor_auth.html.twig
  27. 154 0
      src/Packagist/WebBundle/Security/TwoFactorAuthManager.php
  28. 67 0
      src/Packagist/WebBundle/Security/TwoFactorAuthRateLimiter.php

+ 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 %}
+                    <div class="checkbox"><label for="_trusted"><input id="_trusted" type="checkbox" name="{{ trustedParameterName }}" /> Trust this computer for 30 days</label></div>
+                {% 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 %}

+ 22 - 0
app/config/config.yml

@@ -135,6 +135,25 @@ hwi_oauth:
             options:
                 csrf: true
 
+scheb_two_factor:
+    security_tokens:
+        - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
+        - HWI\Bundle\OAuthBundle\Security\Core\Authentication\Token\OAuthToken
+
+    backup_codes:
+        enabled: true
+        manager: Packagist\WebBundle\Security\TwoFactorAuthManager
+
+    totp:
+        enabled: true
+        server_name: '%packagist_host%'
+        issuer: Packagist
+        window: 1
+
+    trusted_device:
+        enabled: true
+        lifetime: 2592000 # 30 days
+
 nelmio_cors:
     defaults:
         allow_origin: ['*']
@@ -162,3 +181,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

+ 10 - 1
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
@@ -60,6 +68,7 @@ security:
         ROLE_EDIT_PACKAGES: ~
         ROLE_ANTISPAM: ~
         ROLE_SPAMMER: ~
+        ROLE_DISABLE_2FA: ~
 
-        ROLE_ADMIN:       [ ROLE_USER, ROLE_UPDATE_PACKAGES, ROLE_EDIT_PACKAGES, ROLE_DELETE_PACKAGES, ROLE_ANTISPAM ]
+        ROLE_ADMIN:       [ ROLE_USER, ROLE_UPDATE_PACKAGES, ROLE_EDIT_PACKAGES, ROLE_DELETE_PACKAGES, ROLE_ANTISPAM, ROLE_DISABLE_2FA ]
         ROLE_SUPERADMIN:  [ ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH ]

+ 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",

+ 528 - 57
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": "e91f865b54abffebaf1fa3d1af9fff46",
+    "content-hash": "14008ad1f434e6913d590ac6eb386d90",
     "packages": [
         {
             "name": "algolia/algoliasearch-client-php",
@@ -63,6 +63,113 @@
             "description": "Algolia Search API Client for PHP",
             "time": "2019-09-11T15:21:08+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"
+            ],
+            "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.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/beberlei/assert.git",
+                "reference": "99508be011753690fe108ded450f5caaae180cfa"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/beberlei/assert/zipball/99508be011753690fe108ded450f5caaae180cfa",
+                "reference": "99508be011753690fe108ded450f5caaae180cfa",
+                "shasum": ""
+            },
+            "require": {
+                "ext-ctype": "*",
+                "ext-json": "*",
+                "ext-mbstring": "*",
+                "ext-simplexml": "*",
+                "php": "^7"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "*",
+                "phpstan/phpstan-shim": "*",
+                "phpunit/phpunit": ">=6.0.0 <8"
+            },
+            "suggest": {
+                "ext-intl": "Needed to allow Assertion::count(), Assertion::isCountable(), Assertion::minCount(), and Assertion::maxCount() to operate on ResourceBundles"
+            },
+            "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"
+            ],
+            "support": {
+                "issues": "https://github.com/beberlei/assert/issues",
+                "source": "https://github.com/beberlei/assert/tree/v3.2.6"
+            },
+            "time": "2019-10-10T10:33:57+00:00"
+        },
         {
             "name": "cebe/markdown",
             "version": "1.2.1",
@@ -498,6 +605,37 @@
             "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"
+            ],
+            "description": "PHP 7.1 enum implementation",
+            "time": "2017-10-25T22:45:27+00:00"
+        },
         {
             "name": "doctrine/annotations",
             "version": "v1.8.0",
@@ -1267,6 +1405,113 @@
             "homepage": "https://github.com/egulias/EmailValidator",
             "time": "2019-08-13T17:33:27+00:00"
         },
+        {
+            "name": "endroid/qr-code",
+            "version": "3.7.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/endroid/qr-code.git",
+                "reference": "eb829c121f6be259e1b8c258fa7f77114020cc72"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/endroid/qr-code/zipball/eb829c121f6be259e1b8c258fa7f77114020cc72",
+                "reference": "eb829c121f6be259e1b8c258fa7f77114020cc72",
+                "shasum": ""
+            },
+            "require": {
+                "bacon/bacon-qr-code": "^2.0",
+                "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": "dev-master"
+            },
+            "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"
+            ],
+            "support": {
+                "issues": "https://github.com/endroid/qr-code/issues",
+                "source": "https://github.com/endroid/qr-code/tree/master"
+            },
+            "time": "2019-10-24T19:09:14+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"
+            ],
+            "description": "Endroid QR Code Bundle",
+            "homepage": "https://github.com/endroid/qr-code-bundle",
+            "time": "2019-04-04T06:55:45+00:00"
+        },
         {
             "name": "ezyang/htmlpurifier",
             "version": "v4.11.0",
@@ -1982,6 +2227,43 @@
             },
             "time": "2019-09-25T14:49:45+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"
+            ],
+            "description": "QR code decoder / reader",
+            "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder/",
+            "time": "2018-04-26T11:41:33+00:00"
+        },
         {
             "name": "knplabs/knp-menu",
             "version": "2.6.0",
@@ -2095,6 +2377,50 @@
             "description": "This bundle provides an integration of the KnpMenu library",
             "time": "2019-09-19T08:51:48+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"
+            ],
+            "description": "A simple library to work with JSON Web Token and JSON Web Signature",
+            "time": "2019-05-24T18:30:49+00:00"
+        },
         {
             "name": "monolog/monolog",
             "version": "1.25.1",
@@ -2161,6 +2487,42 @@
             "homepage": "http://github.com/Seldaek/monolog",
             "time": "2019-09-06T13:49:17+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"
+            ],
+            "description": "PHP Enum implementation",
+            "homepage": "http://github.com/myclabs/php-enum",
+            "time": "2019-08-19T13:53:00+00:00"
+        },
         {
             "name": "nelmio/cors-bundle",
             "version": "1.5.6",
@@ -2322,6 +2684,40 @@
             "description": "Pagination for PHP",
             "time": "2019-07-17T20:56:16+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"
+            ],
+            "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
+            "time": "2019-01-03T20:26:31+00:00"
+        },
         {
             "name": "paragonie/random_compat",
             "version": "v2.0.18",
@@ -3322,18 +3718,68 @@
             "description": "A polyfill for getallheaders.",
             "time": "2019-03-08T08:55:37+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"
+            ],
+            "description": "Provides two-factor authentication for Symfony applications",
+            "homepage": "https://github.com/scheb/two-factor-bundle",
+            "time": "2019-09-02T18:36:37+00:00"
+        },
         {
             "name": "seld/jsonlint",
-            "version": "1.7.1",
+            "version": "1.7.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Seldaek/jsonlint.git",
-                "reference": "d15f59a67ff805a44c50ea0516d2341740f81a38"
+                "reference": "e2e5d290e4d2a4f0eb449f510071392e00e10d19"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/d15f59a67ff805a44c50ea0516d2341740f81a38",
-                "reference": "d15f59a67ff805a44c50ea0516d2341740f81a38",
+                "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/e2e5d290e4d2a4f0eb449f510071392e00e10d19",
+                "reference": "e2e5d290e4d2a4f0eb449f510071392e00e10d19",
                 "shasum": ""
             },
             "require": {
@@ -3369,7 +3815,11 @@
                 "parser",
                 "validator"
             ],
-            "time": "2018-01-24T12:46:19+00:00"
+            "support": {
+                "issues": "https://github.com/Seldaek/jsonlint/issues",
+                "source": "https://github.com/Seldaek/jsonlint/tree/1.7.2"
+            },
+            "time": "2019-10-24T14:27:39+00:00"
         },
         {
             "name": "seld/phar-utils",
@@ -3715,6 +4165,48 @@
             "homepage": "https://github.com/snc/SncRedisBundle",
             "time": "2019-10-09T07:26:03+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"
+            ],
+            "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",
+            "time": "2019-03-18T10:08:51+00:00"
+        },
         {
             "name": "swiftmailer/swiftmailer",
             "version": "v6.2.1",
@@ -5381,80 +5873,65 @@
         },
         {
             "name": "phpdocumentor/reflection-common",
-            "version": "1.0.1",
+            "version": "2.0.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
-                "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6"
+                "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6",
-                "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/63a995caa1ca9e5590304cd845c15ad6d482a62a",
+                "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5"
+                "php": ">=7.1"
             },
             "require-dev": {
-                "phpunit/phpunit": "^4.6"
+                "phpunit/phpunit": "~6"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.0.x-dev"
+                    "dev-master": "2.x-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "phpDocumentor\\Reflection\\": [
-                        "src"
-                    ]
+                    "phpDocumentor\\Reflection\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
                 "MIT"
             ],
-            "authors": [
-                {
-                    "name": "Jaap van Otterdijk",
-                    "email": "opensource@ijaap.nl"
-                }
-            ],
             "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
             "homepage": "http://www.phpdoc.org",
-            "keywords": [
-                "FQSEN",
-                "phpDocumentor",
-                "phpdoc",
-                "reflection",
-                "static analysis"
-            ],
-            "time": "2017-09-11T18:02:19+00:00"
+            "time": "2018-08-07T13:53:10+00:00"
         },
         {
             "name": "phpdocumentor/reflection-docblock",
-            "version": "4.3.1",
+            "version": "4.3.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
-                "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c"
+                "reference": "b83ff7cfcfee7827e1e78b637a5904fe6a96698e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c",
-                "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/b83ff7cfcfee7827e1e78b637a5904fe6a96698e",
+                "reference": "b83ff7cfcfee7827e1e78b637a5904fe6a96698e",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.0",
-                "phpdocumentor/reflection-common": "^1.0.0",
-                "phpdocumentor/type-resolver": "^0.4.0",
+                "phpdocumentor/reflection-common": "^1.0.0 || ^2.0.0",
+                "phpdocumentor/type-resolver": "~0.4 || ^1.0.0",
                 "webmozart/assert": "^1.0"
             },
             "require-dev": {
-                "doctrine/instantiator": "~1.0.5",
+                "doctrine/instantiator": "^1.0.5",
                 "mockery/mockery": "^1.0",
                 "phpunit/phpunit": "^6.4"
             },
@@ -5476,54 +5953,48 @@
                 "MIT"
             ],
             "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
-            "time": "2019-04-30T17:48:53+00:00"
+            "time": "2019-09-12T14:27:41+00:00"
         },
         {
             "name": "phpdocumentor/type-resolver",
-            "version": "0.4.0",
+            "version": "1.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/TypeResolver.git",
-                "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7"
+                "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/9c977708995954784726e25d0cd1dddf4e65b0f7",
-                "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7",
+                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/2e32a6d48972b2c1976ed5d8967145b6cec4a4a9",
+                "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5 || ^7.0",
-                "phpdocumentor/reflection-common": "^1.0"
+                "php": "^7.1",
+                "phpdocumentor/reflection-common": "^2.0"
             },
             "require-dev": {
-                "mockery/mockery": "^0.9.4",
-                "phpunit/phpunit": "^5.2||^4.8.24"
+                "ext-tokenizer": "^7.1",
+                "mockery/mockery": "~1",
+                "phpunit/phpunit": "^7.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.0.x-dev"
+                    "dev-master": "1.x-dev"
                 }
             },
             "autoload": {
                 "psr-4": {
-                    "phpDocumentor\\Reflection\\": [
-                        "src/"
-                    ]
+                    "phpDocumentor\\Reflection\\": "src"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
             "license": [
                 "MIT"
             ],
-            "authors": [
-                {
-                    "name": "Mike van Riel",
-                    "email": "me@mikevanriel.com"
-                }
-            ],
-            "time": "2017-07-14T14:27:02+00:00"
+            "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+            "time": "2019-08-22T18:11:29+00:00"
         },
         {
             "name": "phpspec/prophecy",

+ 108 - 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,110 @@ 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 (!($this->isGranted('ROLE_DISABLE_2FA') || $user->getId() === $this->getUser()->getId())) {
+            throw new AccessDeniedException('You cannot change this user\'s two-factor authentication settings');
+        }
+
+        if ($user->getId() === $this->getUser()->getId()) {
+            $backupCode = $this->get('session')->remove('backup_code');
+        }
+
+        return array('user' => $user, 'backup_code' => $backupCode ?? null);
+    }
+
+    /**
+     * @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()) {
+                $authManager = $this->get(TwoFactorAuthManager::class);
+                $authManager->enableTwoFactorAuth($user, $enableRequest->getSecret());
+                $backupCode = $authManager->generateAndSaveNewBackupCode($user);
+
+                $this->addFlash('success', 'Two-factor authentication has been enabled.');
+                $this->get('session')->set('backup_code', $backupCode);
+
+                return $this->redirectToRoute('user_2fa_confirm', array('name' => $user->getUsername()));
+            }
+        }
+
+        return array('user' => $user, 'provisioningUri' => $authenticator->getQRContent($user), 'secret' => $enableRequest->getSecret(), 'form' => $form->createView());
+    }
+
+    /**
+     * @Template()
+     * @Route("/users/{name}/2fa/confirm", name="user_2fa_confirm", methods={"GET"})
+     * @ParamConverter("user", options={"mapping": {"name": "username"}})
+     */
+    public function confirmTwoFactorAuthAction(User $user)
+    {
+        if ($user->getId() !== $this->getUser()->getId()) {
+            throw new AccessDeniedException('You cannot change this user\'s two-factor authentication settings');
+        }
+
+        $backupCode = $this->get('session')->remove('backup_code');
+
+        if (empty($backupCode)) {
+            return $this->redirectToRoute('user_2fa_configure', ['name' => $user->getUsername()]);
+        }
+
+        return array('user' => $user, 'backup_code' => $backupCode);
+    }
+
+    /**
+     * @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 (!($this->isGranted('ROLE_DISABLE_2FA') || $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, 'Manually disabled');
+
+            $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

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

@@ -14,6 +14,10 @@ namespace Packagist\WebBundle\Entity;
 
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\ORM\Mapping as ORM;
+use Scheb\TwoFactorBundle\Model\BackupCodeInterface;
+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 +57,7 @@ use FOS\UserBundle\Model\User as BaseUser;
  *     )
  * })
  */
-class User extends BaseUser
+class User extends BaseUser implements TwoFactorInterface, BackupCodeInterface
 {
     /**
      * @ORM\Id
@@ -120,6 +124,18 @@ class User extends BaseUser
      */
     private $failureNotifications = true;
 
+    /**
+     * @ORM\Column(name="totpSecret", type="string", nullable=true)
+     * @var string|null
+     */
+    private $totpSecret;
+
+    /**
+     * @ORM\Column(type="string", length=8, nullable=true)
+     * @var string|null
+     */
+    private $backupCode;
+
     public function __construct()
     {
         $this->packages = new ArrayCollection();
@@ -318,4 +334,44 @@ 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);
+    }
+
+    public function isBackupCode(string $code): bool
+    {
+        return $this->backupCode !== null && hash_equals($this->backupCode, $code);
+    }
+
+    public function invalidateBackupCode(string $code): void
+    {
+        $this->backupCode = null;
+    }
+
+    public function invalidateAllBackupCodes(): void
+    {
+        $this->backupCode = null;
+    }
+
+    public function setBackupCode(string $code): void
+    {
+        $this->backupCode = $code;
+    }
 }

+ 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)));
     }

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

@@ -183,6 +183,24 @@ 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'
+            - '@session.flash_bag'
+            - { from: '%mailer_from_email%', fromName: '%mailer_from_name%' }
+
+    Packagist\WebBundle\Security\TwoFactorAuthRateLimiter:
+        class: Packagist\WebBundle\Security\TwoFactorAuthRateLimiter
+        arguments:
+            - "@snc_redis.cache_client"
+        tags:
+            - { name: kernel.event_subscriber }
+
     scheduler:
         public: true
         class: Packagist\WebBundle\Service\Scheduler

+ 11 - 0
src/Packagist/WebBundle/Resources/public/css/main.css

@@ -1625,6 +1625,15 @@ svg.chart {
   position: relative;
 }
 
+.two-factor-key {
+    max-width: 200px;
+    text-align: center;
+    overflow-wrap: break-word;
+}
+.two-factor-backup-code {
+    font-size: 3em;
+}
+
 
 [class^="icon-"]:before,
 [class*=" icon-"]:before {
@@ -1642,6 +1651,7 @@ svg.chart {
     speak: none;
 }
 
+.icon-check:before { content: '\2611'; } /* '☑' */
 .icon-tools:before { content: '\2692'; } /* '⚒' */
 .icon-cogs:before { content: '\26ef'; } /* '⛯' */
 .icon-mail:before { content: '\2709'; } /* '✉' */
@@ -1665,6 +1675,7 @@ svg.chart {
 .icon-chart:before { content: '📈'; } /* '\1f4c8' */
 .icon-megaphone:before { content: '📣'; } /* '\1f4e3' */
 .icon-newspaper:before { content: '📰'; } /* '\1f4f0' */
+.icon-mobile:before { content: '📱'; } /* '\1f4f1' */
 .icon-key:before { content: '🔑'; } /* '\1f511' */
 .icon-lock:before { content: '🔒'; } /* '\1f512' */
 .icon-lock-open:before { content: '🔓'; } /* '\1f513' */

+ 184 - 0
src/Packagist/WebBundle/Resources/public/font/config.json

@@ -0,0 +1,184 @@
+{
+  "name": "",
+  "css_prefix_text": "icon-",
+  "css_use_suffix": false,
+  "hinting": true,
+  "units_per_em": 1000,
+  "ascent": 850,
+  "glyphs": [
+    {
+      "uid": "6a7b9d4863bb7e6c79e9457a72d689b6",
+      "css": "tools",
+      "code": 9874,
+      "src": "entypo"
+    },
+    {
+      "uid": "1e32e1f8787cf3cb9f52f8850822dcb6",
+      "css": "cogs",
+      "code": 9967,
+      "src": "elusive"
+    },
+    {
+      "uid": "e335adbc2d898c7d85d40c507796e7b4",
+      "css": "mail",
+      "code": 9993,
+      "src": "entypo"
+    },
+    {
+      "uid": "6274e0601f2feef7eced89146e708de0",
+      "css": "user-add",
+      "code": 59136,
+      "src": "entypo"
+    },
+    {
+      "uid": "457c8e2b305e7af74c1be4f07a01ca92",
+      "css": "vcard",
+      "code": 59170,
+      "src": "entypo"
+    },
+    {
+      "uid": "96ea1be71f597a5bdfc8f791ada4f651",
+      "css": "archive",
+      "code": 59192,
+      "src": "entypo"
+    },
+    {
+      "uid": "91426c82d94428a33353e495418435e3",
+      "css": "share",
+      "code": 59196,
+      "src": "entypo"
+    },
+    {
+      "uid": "89eb512cb82a1c3fe83cb16134f9876c",
+      "css": "back-in-time",
+      "code": 59249,
+      "src": "entypo"
+    },
+    {
+      "uid": "8b9e6a8dd8f67f7c003ed8e7e5ee0857",
+      "css": "off",
+      "code": 59278,
+      "src": "fontawesome"
+    },
+    {
+      "uid": "8a1d446e5555e76f82ddb1c8b526f579",
+      "css": "flow-tree",
+      "code": 59282,
+      "src": "entypo"
+    },
+    {
+      "uid": "db112402805d9dadc01ce009fbfdb914",
+      "css": "paper-plane",
+      "code": 59291,
+      "src": "entypo"
+    },
+    {
+      "uid": "2bbde1076919db3d1dcc7f6b43d19bd4",
+      "css": "traffic-cone",
+      "code": 59299,
+      "src": "entypo"
+    },
+    {
+      "uid": "aa18911cd641967c899c575aeb80cd7e",
+      "css": "github",
+      "code": 61595,
+      "src": "elusive"
+    },
+    {
+      "uid": "12d16dda691bda304e3b57d7bc0de5a9",
+      "css": "lightbulb",
+      "code": 61675,
+      "src": "typicons"
+    },
+    {
+      "uid": "26969cfbd811730075e4c657cc9fda2a",
+      "css": "twitter",
+      "code": 62217,
+      "src": "elusive"
+    },
+    {
+      "uid": "b22fdf6cbaa9c54fbb0bc9abb0ed4098",
+      "css": "box",
+      "code": 62256,
+      "src": "entypo"
+    },
+    {
+      "uid": "41d534223ef447a01af3e2f629ec70eb",
+      "css": "leaf",
+      "code": 127810,
+      "src": "entypo"
+    },
+    {
+      "uid": "bbd66ef66bb8fa9edde54d9a90b89150",
+      "css": "user",
+      "code": 128100,
+      "src": "entypo"
+    },
+    {
+      "uid": "ecf8edb95c3f45eb433b4cce7ba9f740",
+      "css": "users",
+      "code": 128101,
+      "src": "entypo"
+    },
+    {
+      "uid": "0ccb084ddeeae372673793ed0b45bb4a",
+      "css": "folder",
+      "code": 128193,
+      "src": "entypo"
+    },
+    {
+      "uid": "7184d8171b6a9b18eabbace94cca21db",
+      "css": "chart",
+      "code": 128200,
+      "src": "entypo"
+    },
+    {
+      "uid": "489090690d8bd1745e365485946e20a8",
+      "css": "megaphone",
+      "code": 128227,
+      "src": "entypo"
+    },
+    {
+      "uid": "da0fd38d651815e3a12f6c030ff1fe5b",
+      "css": "newspaper",
+      "code": 128240,
+      "src": "entypo"
+    },
+    {
+      "uid": "8beac4a5fd5bed9f82ca7a96cc8ba218",
+      "css": "key",
+      "code": 128273,
+      "src": "entypo"
+    },
+    {
+      "uid": "1a7104205ea96e6f40ac716d0ca72f21",
+      "css": "lock",
+      "code": 128274,
+      "src": "entypo"
+    },
+    {
+      "uid": "e81fc3a0d39ace6ee735d7b24eedd56d",
+      "css": "lock-open",
+      "code": 128275,
+      "src": "entypo"
+    },
+    {
+      "uid": "9c7fd7637a41b59a358cb70893f945a5",
+      "css": "rocket",
+      "code": 128640,
+      "src": "entypo"
+    },
+    {
+      "uid": "767fede84586366cd7d6c835be745454",
+      "css": "mobile",
+      "code": 128241,
+      "src": "entypo"
+    },
+    {
+      "uid": "dd6c6b221a1088ff8a9b9cd32d0b3dd5",
+      "css": "check",
+      "code": 9745,
+      "src": "fontawesome"
+    }
+  ]
+}

BIN
src/Packagist/WebBundle/Resources/public/font/fontello.eot


+ 65 - 135
src/Packagist/WebBundle/Resources/public/font/fontello.svg

@@ -1,138 +1,68 @@
 <?xml version="1.0" standalone="no"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
 <svg xmlns="http://www.w3.org/2000/svg">
-<metadata>
-Created by FontForge 20100429 at Mon Feb 25 20:22:10 2013
- By root
-Copyright (C) 2012 by original authors @ fontello.com
-</metadata>
+<metadata>Copyright (C) 2019 by original authors @ fontello.com</metadata>
 <defs>
-<font id="fontello" horiz-adv-x="1030" >
-  <font-face 
-    font-family="fontello"
-    font-weight="500"
-    font-stretch="normal"
-    units-per-em="1000"
-    panose-1="2 0 6 3 0 0 0 0 0 0"
-    ascent="850"
-    descent="-150"
-    bbox="14.5185 -142 1086 850"
-    underline-thickness="50"
-    underline-position="-100"
-    unicode-range="U+2692-1F680"
-  />
-<missing-glyph horiz-adv-x="364" 
-d="M33 0v666h265v-666h-265zM66 33h199v600h-199v-600z" />
-    <glyph glyph-name=".notdef" horiz-adv-x="364" 
-d="M33 0v666h265v-666h-265zM66 33h199v600h-199v-600z" />
-    <glyph glyph-name=".null" horiz-adv-x="0" 
- />
-    <glyph glyph-name="nonmarkingreturn" horiz-adv-x="333" 
- />
-    <glyph glyph-name="uni2692" unicode="&#x2692;" 
-d="M170 506q-14 -14 -14 -47q0 -11 -2 -11q-1 -1 -18 -16t-18 -16q-16 -13 -28 4l-70 76q-3 4 -4 8t-0.5 7t4 6.5t6 5.5t7.5 5.5t7 5.5q15 11 20 16q6 6 27 6q22 0 36.5 14.5t18.5 37.5q5 25 10 30q2 0 9 7q18 18 67 53q134 90 186 96q114 0 148 -2q13 0 -8 -8
-q-123 -54 -152 -76q-80 -56 -36 -114q35 -47 38 -48q8 -8 -2 -14q-8 -8 -38 -35t-38 -35q-14 -8 -18 -4q-42 48 -71 60t-67 -12zM866 4q17 -22 -2 -38l-48 -42q-22 -13 -38 4l-414 472q-8 8 0 20l72 62q13 8 20 -2zM994 516q-49 -88 -154 -62q-56 12 -100 -32l-82 -78
-l-68 78l68 70q40 40 37 118q-1 34 5 58q12 56 140 112q12 6 18 -3t2 -15q-10 -10 -46 -80q-13 -10 -12 -35q1 -24 40 -53q58 -40 96 22q2 4 24.5 38t23.5 36q4 10 13 9q10 -2 11 -17q16 -103 -16 -166zM152 -2l254 248l76 -86l-246 -242q-20 -20 -38 -4l-46 46q-23 18 0 38z
-" />
-    <glyph glyph-name="uni26EF" unicode="&#x26ef;" horiz-adv-x="1101" 
-d="M15 402q0 15 13 17l86 14q6 19 18 42q-23 32 -50 64q-4 5 -4 11q0 7 4 11q11 15 45 49t45 34q7 0 12 -4l64 -50q20 11 43 18q5 53 13 86q4 13 16 13h104q15 0 17 -14l13 -85q22 -7 41 -17l66 49q4 4 11 4t12 -4q81 -73 81 -90q0 -5 -4 -10l-23 -30q-21 -27 -26 -34
-q13 -25 19 -46l85 -12q13 -2 13 -17v-103q0 -15 -13 -17l-86 -14q-6 -19 -18 -42q23 -32 50 -64q4 -5 4 -11q0 -7 -4 -11q-14 -17 -46.5 -50t-43.5 -33q-6 0 -11 4l-64 50q-24 -12 -43 -17q-6 -55 -13 -87q-4 -13 -17 -13h-104q-14 0 -16 14l-13 85q-24 7 -42 17l-66 -49
-q-4 -4 -11 -4t-12 4q-80 73 -80 90q0 5 4 10l24 32l25 32q-14 25 -20 46l-85 13q-13 2 -13 16v103zM229 350q0 -58 42.5 -100.5t100.5 -42.5t100.5 42.5t42.5 100.5t-42.5 100.5t-100.5 42.5t-100.5 -42.5t-42.5 -100.5zM855 -77q-5 -9 -27 -37t-27 -28l-7 4l-26 15l-36 21
-q-3 3 -3 4q0 13 29 77q-11 14 -17 29q-83 9 -83 17v78q0 9 83 18q5 11 17 29q-29 64 -29 77q0 2 1 3.5t3 2.5t4.5 2t6.5 3t7 4l16 10q11 6 17 9q15 9 17 9q5 0 27 -28t27 -36q7 1 17 1t17 -1q33 45 51 63l4 1q2 0 69 -39q2 -2 2 -4q0 -15 -28 -77q9 -13 16 -29q83 -9 83 -18
-v-78q0 -8 -83 -17q-7 -17 -16 -29q28 -62 28 -77q0 -2 -2 -4l-37 -21l-25 -15l-7 -4q-5 0 -27.5 28.5t-27.5 36.5q-14 -2 -17 -2t-17 2zM855 494q-5 -8 -27 -36t-27 -28q-1 0 -69 39q-3 3 -3 4q0 13 29 77q-11 14 -17 29q-83 9 -83 18v78q0 8 83 17q5 11 17 29
-q-29 64 -29 77q0 2 1 3.5t3 2.5t4.5 2t6.5 3t7 4l16 9q11 7 17 10q15 9 17 9q5 0 27 -28t27 -36q14 2 17 2t17 -2q33 45 51 63l4 1q2 0 69 -39q2 -2 2 -4q0 -15 -28 -77q9 -13 16 -29q83 -9 83 -17v-78q0 -9 -83 -18q-7 -17 -16 -29q28 -62 28 -77q0 -2 -2 -4
-q-67 -39 -69 -39q-5 0 -27.5 28t-27.5 36q-7 -1 -17 -1t-17 1zM801 64q0 -29 21 -50t50 -21t50.5 21t21.5 50q0 28 -22 50t-50 22t-49.5 -21.5t-21.5 -50.5zM801 636q0 -29 21 -50.5t50 -21.5t50.5 21.5t21.5 50.5q0 28 -21.5 49.5t-50.5 21.5q-28 0 -49.5 -21.5
-t-21.5 -49.5z" />
-    <glyph glyph-name="uni2709" unicode="&#x2709;" horiz-adv-x="930" 
-d="M17 626q2 14 26 14h846q38 0 20 -32q-8 -15 -24 -22l-97 -51l-180 -97q-93 -50 -97 -52q-16 -10 -46 -10q-29 0 -46 10q-4 2 -97 52l-180 97l-97 51q-32 18 -28 40zM895 486q20 11 20 -10v-368q0 -16 -17 -32t-33 -16h-800q-16 0 -33 16t-17 32v368q0 21 20 10l384 -200
-q17 -10 46 -10t46 10z" />
-    <glyph glyph-name="uniE700" unicode="&#xe700;" 
-d="M815 6v-106h-800v202q41 16 82 26q95 34 129 69q35 35 35 95q0 22 -23 48q-24 27 -31 74q-1 11 -23 25q-21 13 -25 61q0 32 14 38l4 4q-1 6 -3 19q-13 53 -8 95.5t39 85.5q46 58 160 58t160 -58t42 -112l-14 -88q18 -8 18 -42q-5 -55 -23 -60q-20 -5 -23 -26
-q-10 -46 -33 -73q-23 -28 -23 -49q0 -60 36 -95t130 -69q180 -65 180 -122zM865 400h150v-100h-150v-150h-100v150h-150v100h150v150h100v-150z" />
-    <glyph glyph-name="uniE70A" unicode="&#xe70a;" 
-d="M26 353q74 120 203.5 201.5t285.5 81.5t285.5 -81.5t203.5 -201.5q11 -20 11 -39q0 -18 -11 -38q-74 -120 -203 -201.5t-286 -81.5q-156 0 -285.5 81.5t-203.5 201.5q-11 20 -11 38q0 19 11 39zM86 314q70 -109 180 -179.5t249 -70.5t249 70.5t180 179.5
-q-85 130 -213 197q34 -58 34 -125q0 -104 -73 -177t-177 -73t-177 73t-73 177q0 67 34 125q-128 -67 -213 -197zM345 386q0 -11 8 -19t19 -8t19 8t8 19q0 47 34.5 81.5t81.5 34.5q11 0 19 8t8 19t-8 18.5t-19 7.5q-69 0 -119.5 -50t-50.5 -119z" />
-    <glyph glyph-name="uniE70B" unicode="&#xe70b;" 
-d="M26 353q85 130 212 207q125 76 277 76q43 0 100 -10l31 54q6 9 15 9q4 0 28 -12l46 -27q9 -6 9 -15v-5l-176 -316l-176 -316l-28 -50q-6 -9 -15 -9q-7 0 -75 39q-9 6 -9 16q0 5 25 49q-162 74 -264 233q-11 19 -11 38q0 20 11 39zM368 184q-45 33 -74 85.5t-29 116.5
-q0 67 34 125q-128 -67 -213 -197q92 -142 239 -209zM345 386q0 -11 8 -19t19 -8t19 8t8 19q0 47 34.5 81.5t81.5 34.5q11 0 19 8t8 19t-8 18.5t-19 7.5q-69 0 -119.5 -50t-50.5 -119zM556 67q124 11 224 79t164 168q-63 98 -158 164l35 63q53 -36 102.5 -86t80.5 -102
-q11 -20 11 -39q0 -18 -11 -38q-42 -70 -111 -132t-144 -98q-111 -53 -234 -53zM604 152l157 281q4 -23 4 -47q0 -82 -46.5 -145t-114.5 -89z" />
-    <glyph glyph-name="uniE722" unicode="&#xe722;" 
-d="M915 750q42 0 71 -29t29 -71v-600q0 -41 -29.5 -70.5t-70.5 -29.5h-800q-40 0 -70 30t-30 70v600q0 41 29.5 70.5t70.5 29.5h800zM915 650h-800v-600h800v600zM465 156h-250v90h250v-90zM465 306h-250v90h250v-90zM465 456h-250v90h250v-90zM811 226l4 -70h-250
-q0 70 6 70q84 22 84 66q0 16 -27 56t-27 88q0 110 90 110t90 -110q0 -48 -28 -88t-28 -56q0 -20 20.5 -35.5t43.5 -22.5z" />
-    <glyph glyph-name="uniE738" unicode="&#xe738;" horiz-adv-x="1011" 
-d="M159 600q0 18 11.5 33t26.5 17h608q20 0 36 -15q14 -14 14 -35v-50h-696v50zM259 700q0 18 11.5 33t26.5 17h410q20 0 36 -15q14 -14 14 -35h-498zM955 550q33 -30 38 -46q6 -18 0 -54l-76 -450q-3 -18 -17.5 -32.5t-30.5 -17.5h-710q-52 0 -60 50q0 1 -39 225t-39 225
-q-10 22 -3 44t10 26q1 2 21 20l40 40v-80h836v80zM707 280v100h-70v-80h-260v80h-68v-100q0 -50 48 -50h300q18 0 33 10.5t15 25.5z" />
-    <glyph glyph-name="uniE73C" unicode="&#xe73c;" horiz-adv-x="830" 
-d="M665 200q62 0 106 -43.5t44 -106.5q0 -62 -44.5 -106t-105.5 -44t-105.5 44t-44.5 106q0 3 1 13t1 13l-260 156q-43 -32 -92 -32q-61 0 -105.5 44t-44.5 106t44.5 106t105.5 44q55 0 92 -30l260 156q0 2 -1 12t-1 12q0 62 44.5 106t105.5 44q62 0 106 -43.5t44 -106.5
-q0 -62 -44.5 -106t-105.5 -44q-52 0 -90 32l-262 -156q2 -10 2 -26q0 -14 -2 -24l262 -156q36 30 90 30z" />
-    <glyph glyph-name="uniE771" unicode="&#xe771;" horiz-adv-x="970" 
-d="M836 640q119 -121 119 -290t-119 -290q-118 -120 -289 -120q-139 0 -252 88l70 76q83 -60 182 -60q126 0 216 90t90 216q0 128 -90 218t-216 90q-124 0 -213 -86q-88 -86 -93 -210h142l-184 -206l-184 206h124q5 166 123 282q119 116 285 116q171 0 289 -120zM511 570h70
-v-204l130 -130l-50 -50l-150 150v234z" />
-    <glyph glyph-name="uniE78E" unicode="&#xe78e;" horiz-adv-x="887" 
-d="M15 350q0 109 47.5 197t123.5 145q28 17 55 13t45 -27.5t14 -52.5t-28 -47q-114 -87 -114 -228q0 -118 84 -202t202 -84t202 84q83 83 83 202q0 141 -114 228q-22 17 -27 46t13 54q16 23 45.5 27.5t54.5 -13.5q76 -58 123.5 -145.5t47.5 -196.5q0 -91 -35 -169.5
-t-90 -133.5q-56 -56 -134.5 -91t-168.5 -35t-168.5 35t-134.5 91t-91 134.5t-35 168.5zM372 421v358q0 28 21.5 49.5t50.5 21.5q28 0 49.5 -21t21.5 -50v-358q0 -29 -21.5 -50t-49.5 -21q-29 0 -50.5 21.5t-21.5 49.5z" />
-    <glyph glyph-name="uniE792" unicode="&#xe792;" horiz-adv-x="970" 
-d="M485 820q49 0 84.5 -35t35.5 -85q0 -76 -72 -110v-140q0 -52 78 -52h100q85 0 129 -54q43 -53 43 -118v-114q72 -34 72 -112q0 -50 -35.5 -85t-84.5 -35t-84.5 35t-35.5 85q0 78 72 112v114q0 78 -76 78h-100q-43 0 -78 12v-204q72 -34 72 -112q0 -50 -35.5 -85
-t-84.5 -35t-84.5 35t-35.5 85q0 78 72 112v204q-30 -12 -76 -12h-100q-35 0 -56.5 -22t-21.5 -56v-114q72 -34 72 -112q0 -50 -35.5 -85t-84.5 -35t-84.5 35t-35.5 85q0 78 72 112v114q0 65 43 118q44 54 131 54h100q76 0 76 52v140q-72 34 -72 110q0 50 35.5 85t84.5 35z
-M205 0q0 29 -20.5 49.5t-49.5 20.5q-28 0 -48 -20.5t-20 -49.5q0 -28 20 -48t48 -20t49 20t21 48zM417 700q0 -28 20 -48t48 -20t49 20t21 48q0 29 -20.5 49.5t-49.5 20.5q-28 0 -48 -20.5t-20 -49.5zM555 0q0 29 -20.5 49.5t-49.5 20.5q-28 0 -48 -20.5t-20 -49.5
-q0 -28 20 -48t48 -20t49 20t21 48zM835 -68q28 0 49 20t21 48q0 29 -20.5 49.5t-49.5 20.5q-28 0 -48 -20.5t-20 -49.5q0 -28 20 -48t48 -20z" />
-    <glyph glyph-name="uniE79B" unicode="&#xe79b;" horiz-adv-x="951" 
-d="M909 720q13 4 21.5 -3t4.5 -19q-3 -8 -73 -313.5t-73 -312.5q-2 -13 -13 -18.5t-25 0.5l-139 75l-139 75l22 26l209 227l145 156q38 41 40 43q4 4 -1 9t-9 1l-550 -402l-302 120q-12 4 -12 12t12 12q7 4 227 82l437 154q217 76 218 76zM327 -8v204l160 -82
-q-122 -108 -142 -128q-18 -14 -18 6z" />
-    <glyph glyph-name="uniE7A3" unicode="&#xe7a3;" horiz-adv-x="991" 
-d="M495 432q58 0 108.5 16.5t61.5 47.5q2 -6 21 -58l35 -100q-5 -38 -70 -65q-66 -27 -156 -27t-156 27q-65 27 -70 65l35 100q19 52 21 58q11 -31 62.5 -47.5t107.5 -16.5zM371 624l52 142q10 34 72 34t74 -34q11 -34 50 -142q-29 -44 -124 -44t-124 44zM935 196
-q41 -16 41 -41.5t-37 -46.5l-352 -188q-37 -22 -91 -22t-91 22l-354 188q-38 20 -36 46t42 42l188 76l-22 -60q0 -47 80 -82q78 -34 192 -34t192 34q81 35 82 82l-22 60z" />
-    <glyph glyph-name="uniF09B" unicode="&#xf09b;" horiz-adv-x="887" 
-d="M15 350q0 123 60 219.5t153 151.5q101 58 216 58q123 0 219.5 -60t151.5 -154q57 -99 57 -215q0 -123 -60 -219.5t-153 -151.5q-101 -58 -215 -58q-123 0 -220 60t-152 154q-57 99 -57 215zM86 350q0 -115 70 -211q69 -95 180 -129v94q-27 -4 -38 -4q-61 0 -85 56
-q-11 29 -32 46q-23 19 -23 22q0 7 15 7q10 0 18.5 -2.5t17 -10t11.5 -10.5t12 -16l9 -12q21 -28 54 -28q21 0 45 8q9 30 35 49q-91 9 -137 47q-44 37 -44 125q0 67 40 111q-8 26 -8 47q0 30 16 60q33 0 56 -10q22 -9 56 -34q40 10 95 10q45 0 85 -9q31 22 56 33q23 10 56 10
-q15 -29 15 -60q0 -23 -8 -47q41 -47 41 -111q0 -89 -45 -126q-44 -37 -137 -46q39 -25 39 -73v-126q111 34 180 129q70 96 70 211q0 75 -29.5 141t-75.5 112t-111 75t-141 29t-141.5 -29t-111.5 -75t-75.5 -112t-29.5 -141z" />
-    <glyph glyph-name="uniF0EB" unicode="&#xf0eb;" horiz-adv-x="601" 
-d="M219 -25q-26 0 -43.5 14.5t-17.5 39.5q0 13 7 26q-25 16 -25 45q0 20 14 36q-14 16 -14 35q0 31 26 46q-4 52 -52 113q-21 27 -42 49q-57 63 -57 150q0 57 25.5 104.5t64.5 77.5q86 68 196 68q109 0 195 -68q39 -30 64.5 -77.5t25.5 -104.5q0 -87 -57 -150
-q-29 -32 -43.5 -51t-31.5 -51q-18 -34 -19 -60q26 -15 26 -46q0 -19 -14 -35q14 -16 14 -36q0 -29 -25 -45q8 -14 8 -26q0 -25 -17.5 -39.5t-43.5 -14.5q-10 -23 -32 -38.5t-50 -15.5t-50 15.5t-32 38.5zM124 428q3 -3 17 -18t17 -19q71 -85 79 -166h127q8 81 79 166
-q3 4 17 19t17 18q38 44 38 101q0 63 -39.5 105t-98.5 61q-37 12 -76 12q-80 0 -145 -47q-70 -51 -70 -131q0 -57 38 -101zM283 600q0 8 5.5 13t12.5 5q65 0 104 -39q21 -21 21 -50q0 -7 -5 -12.5t-13 -5.5t-13 5.5t-5 12.5q0 25 -30 39q-31 14 -59 14q-7 0 -12.5 5t-5.5 13z
-" />
-    <glyph glyph-name="uniF309" unicode="&#xf309;" horiz-adv-x="950" 
-d="M935 636q-37 -56 -94 -98v-24q0 -130 -60 -250q-60 -121 -186 -203q-127 -83 -290 -83q-159 0 -290 84q18 -2 46 -2q131 0 234 80q-64 2 -111.5 39t-64.5 93q9 -4 34 -4q26 0 50 6q-63 13 -106.5 64.5t-43.5 121.5v2q36 -20 84 -24q-84 58 -84 158q0 47 26 94
-q155 -189 390 -196q-6 18 -6 42q0 78 55.5 133t134.5 55q82 0 136 -58q58 12 120 44q-19 -66 -82 -104q55 8 108 30z" />
-    <glyph glyph-name="uniF330" unicode="&#xf330;" horiz-adv-x="990" 
-d="M301 806l194 -158l-284 -184l-196 164zM681 54l116 78v-46l-302 -190l-302 190v46l118 -78q6 -2 12 -2q10 0 14 4l158 132l160 -132q4 -4 14 -4q6 0 12 2zM975 628l-194 -164l-286 184l196 158zM495 286l286 178l174 -140l-282 -184zM319 140l-282 184l174 140l284 -178z
-" />
-    <glyph glyph-name="u1F342" unicode="&#x1f342;" horiz-adv-x="970" 
-d="M251 646q182 107 506 66q169 -23 196 -50q4 -5 -2 -10q-76 -40 -130 -109t-78 -132q-23 -61 -65 -132q-40 -69 -93 -105q-137 -96 -382 -4q-67 -78 -114 -176q-12 -24 -47 -7t-25 39q43 100 129 193q87 95 176 153q179 119 317 174l54 20q-65 0 -148.5 -15.5
-t-144.5 -37.5q-70 -25 -162 -84q-89 -57 -161 -141q-23 242 174 358z" />
-    <glyph glyph-name="u1F464" unicode="&#x1f464;" horiz-adv-x="970" 
-d="M751 128q204 -72 204 -122v-106h-940v106q0 50 204 122q93 34 128 69q34 34 34 95q0 23 -22 49q-21 26 -32 73q-3 21 -23 26q-18 5 -23 60q0 32 14 38l4 4q-7 46 -12 88q-4 53 41 112q45 58 157 58t158 -58t40 -112l-12 -88q18 -8 18 -42q-5 -55 -23 -60q-20 -5 -23 -26
-q-7 -47 -31 -74q-23 -26 -23 -48q0 -60 35 -95q34 -34 127 -69z" />
-    <glyph glyph-name="u1F465" unicode="&#x1f465;" 
-d="M791 -90v150q0 54 -30 81q-32 29 -154 89q40 30 40 84q0 17 -13 33t-19 51q-2 8 -14 16q-13 8 -14 42q0 24 12 30q-10 40 -8 73.5t23 64.5q27 40 95 40t96 -40q29 -40 24 -78l-8 -60q12 -6 12 -30q-1 -34 -14 -42q-12 -8 -14 -16q-6 -35 -19 -51t-13 -33q0 -42 21 -66
-t77 -48q113 -47 130 -80q6 -7 9 -61q4 -71 5 -101v-48h-224zM527 172q182 -78 182 -124v-138h-694v184q0 44 84 78q73 30 104 64q28 31 28 88q0 21 -19 44t-25 68q-1 10 -18 22q-15 11 -20 56q0 26 10 36l4 2l-5 46l-5 36q-5 50 33 103t127 53t127 -53t33 -103l-10 -82
-q14 -8 14 -38q-5 -45 -20 -56q-17 -12 -18 -22q-6 -45 -25 -68t-19 -44q0 -57 28 -88q31 -34 104 -64z" />
-    <glyph glyph-name="u1F4B3" unicode="&#x1f4b3;" 
-d="M915 700q41 0 70.5 -29.5t29.5 -70.5v-500q0 -42 -29 -71t-71 -29h-800q-41 0 -70.5 29.5t-29.5 70.5v500q0 40 30 70t70 30h800zM915 400h-800v-300h800v300zM915 600h-800v-50h800v50zM245 294v-30h-30v30h30zM425 234v30h30v30h60v-30h-30v-30h-30v-30h-60v30h30z
-M485 204v30h30v-30h-30zM305 204v30h60v-30h-60zM395 234h-30v60h60v-30h-30v-30zM275 234v-30h-60v30h30v30h30v30h60v-30h-30v-30h-30z" />
-    <glyph glyph-name="u1F4C1" unicode="&#x1f4c1;" horiz-adv-x="1031" 
-d="M969 500q29 0 38.5 -10.5t7.5 -37.5l-42 -452q-2 -27 -13.5 -38.5t-40.5 -11.5h-806q-51 0 -56 50l-42 452q-2 27 7.5 37.5t38.5 10.5h908zM935 610l10 -40h-846l14 132q4 19 20 33.5t36 14.5h164q52 0 86 -34l30 -30q31 -36 86 -36h340q20 0 38 -12t22 -28z" />
-    <glyph glyph-name="u1F4C8" unicode="&#x1f4c8;" horiz-adv-x="1033" 
-d="M49 284q-42 11 -32 56q11 42 54 32l98 -24l-52 -80zM939 272q14 12 33.5 11t30.5 -15q33 -32 -2 -64l-252 -226q-12 -12 -30 -12q-15 0 -28 10l-286 220l-54 14l50 80l36 -8q13 -5 16 -8l264 -204zM449 492l-350 -550q-12 -22 -38 -22q-25 0 -39 24t1 46l374 588
-q7 15 28 20q18 6 36 -6l246 -156l226 326q10 16 27.5 19t34.5 -9q38 -24 12 -62l-252 -362q-24 -36 -62 -12z" />
-    <glyph glyph-name="u1F4E3" unicode="&#x1f4e3;" horiz-adv-x="890" 
-d="M873 242q9 -120 -39 -140q-28 -12 -61 3q-36 16 -65 40q-37 28 -106.5 42t-141.5 7q-26 -4 -41.5 -18t-6.5 -38q26 -65 46 -108q4 -10 24 -22q19 -12 24 -20q13 -34 -22 -46q-45 -20 -102 -40q-30 -10 -54 42q-23 56 -58 132q-6 13 -34 17q-27 4 -46 31l-20 -7
-q-14 -5 -19 -6.5t-16 -3.5t-18 -0.5t-17 5t-22 10.5q-40 24 -54 60q-16 30 -5 79q11 47 43 61q126 52 213 108q84 54 124 103q34 42 59 92q22 44 25 78q7 76 51 95q48 19 130 -70t142 -228q58 -137 67 -258zM778 200q9 5 10 38q2 38 -11 98q-27 133 -108 251q-40 58 -67 84
-q-29 27 -36 23q-9 -5 -10 -42q-2 -40 10 -105q25 -141 108 -252q44 -57 68 -76q29 -23 36 -19z" />
-    <glyph glyph-name="u1F4F0" unicode="&#x1f4f0;" horiz-adv-x="830" 
-d="M715 800q42 0 71 -29t29 -71v-700q0 -41 -29.5 -70.5t-70.5 -29.5h-600q-40 0 -70 30t-30 70v700q0 41 29.5 70.5t70.5 29.5h600zM715 700h-600v-700h600v700zM465 200h-250v50h250v-50zM615 400h-200v50h200v-50zM415 600h200v-100h-200v100zM365 400h-150v200h150v-200
-zM315 300h-100v50h100v-50zM365 350h250v-50h-250v50zM615 100h-400v50h400v-50zM515 200v50h100v-50h-100z" />
-    <glyph glyph-name="u1F511" unicode="&#x1f511;" horiz-adv-x="810" 
-d="M761 397q-48 -99 -150 -117q-67 -12 -130 -2l-118 -194l-70 -12l-104 -166q-13 -27 -46 -32l-76 -14q-13 -4 -22.5 4.5t-11.5 21.5l-16 98q-8 30 12 56l258 386q-25 51 -38 120q-10 57 7 108t52.5 87t81.5 60t97 33q107 20 195 -45q89 -65 107 -177q20 -117 -28 -215z
-M663 536q30 49 20 100t-50 80q-53 37 -109 17.5t-75 -72.5q-4 -10 4 -36q4 -13 31 -32q12 -8 67 -49q6 -5 42 -28q13 -8 19 -8q32 0 51 28z" />
-    <glyph glyph-name="u1F512" unicode="&#x1f512;" horiz-adv-x="673" 
-d="M69 -7q-23 0 -38.5 15.5t-15.5 37.5v322q0 22 15.5 37.5t38.5 15.5h17v108q0 102 74 176t176 74q103 0 177 -74q73 -73 73 -176v-108h18q23 0 38.5 -15.5t15.5 -37.5v-322q0 -22 -15.5 -37.5t-38.5 -15.5h-535zM336 671q-58 0 -100 -41.5t-42 -100.5v-108h285v108
-q0 58 -42 100t-101 42zM265 279q0 -44 39 -64l-38 -128q-3 -9 2.5 -16t14.5 -7h107q9 0 14.5 7t2.5 16l-38 128q39 20 39 64q0 29 -21.5 50t-50.5 21t-50 -21t-21 -50z" />
-    <glyph glyph-name="u1F513" unicode="&#x1f513;" horiz-adv-x="959" 
-d="M69 -7q-23 0 -38.5 15.5t-15.5 37.5v322q0 22 15.5 37.5t38.5 15.5h375v108q0 103 73 176q74 74 177 74q102 0 176 -74t74 -176v-143q0 -14 -11 -25t-25 -11h-36q-14 0 -25 11t-11 25v143q0 59 -42 100.5t-100 41.5q-59 0 -101 -42t-42 -100v-108h53q23 0 38.5 -15.5
-t15.5 -37.5v-322q0 -22 -15.5 -37.5t-38.5 -15.5h-535zM265 82q0 -8 5.5 -13t12.5 -5h107q8 0 13 5t5 13q0 4 -9 32l-19 64l-11 37q39 19 39 64q0 29 -21.5 50t-50.5 21t-50 -21t-21 -50q0 -44 39 -64l-1 -4q-1 -4 -4 -13l-6 -20q-3 -9 -20 -64q-8 -25 -8 -32z" />
-    <glyph glyph-name="u1F680" unicode="&#x1f680;" horiz-adv-x="890" 
-d="M558 236q7 -56 8 -81q2 -33 -8 -59q-4 -10 -7 -22t-4.5 -16t-6 -10t-11 -11t-19.5 -13l-33 -20q-11 -6 -16 -8.5t-27 -11.5l-39 -17q-46 -21 -85 -37q-30 -11 -44.5 3.5t-3.5 44.5l40 110l-130 132l-106 -40q-30 -13 -43.5 2.5t-2.5 45.5l16 42l25 62q12 29 17 40
-q37 75 47 81q2 1 12 8l17 12q17 13 41 13h52l71 -6q37 52 106 124q64 66 122.5 107.5t140.5 71.5q85 31 165 19q17 0 20 -20q10 -85 -18.5 -170.5t-75.5 -148.5q-99 -133 -184 -199zM608 532q22 -22 54 -22t54 23t22 55t-22 55t-54 23t-54 -23t-22 -55q0 -33 22 -56z" />
-  </font>
-</defs></svg>
+<font id="fontello" horiz-adv-x="1000" >
+<font-face font-family="fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
+<missing-glyph horiz-adv-x="1000" />
+<glyph glyph-name="check" unicode="&#x2611;" d="M786 331v-177q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h464q35 0 65-14 9-4 10-13 2-10-5-16l-27-28q-6-5-13-5-1 0-5 1-13 3-25 3h-464q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v141q0 8 5 13l36 35q6 6 13 6 3 0 7-2 11-4 11-16z m129 273l-455-454q-13-14-31-14t-32 14l-240 240q-14 13-14 31t14 32l61 62q14 13 32 13t32-13l147-147 361 361q13 13 31 13t32-13l62-61q13-14 13-32t-13-32z" horiz-adv-x="928.6" />
+
+<glyph glyph-name="tools" unicode="&#x2692;" d="M155 506q-8-8-11-22t-3-25-2-11q-2-2-17-15t-19-17q-16-14-28 4l-70 76q-11 12 2 24 2 2 18 14t20 16q6 6 27 6t37 14q14 14 18 38t10 30q2 0 9 7t26 22 41 31q134 90 186 96 122 0 148-2 12 0-8-8-120-52-152-76-80-56-36-114 34-46 38-48 8-8-2-14-2-2-38-35t-38-35q-14-8-18-4-42 48-71 60t-67-12z m286-26l410-476q18-22-2-38l-48-42q-22-14-38 4l-414 472q-8 8 0 20l72 62q12 8 20-2z m554 202q16-104-16-166-50-88-154-62-56 12-100-32l-82-78-68 78 68 70q24 24 31 53t6 65 5 58q12 56 140 112 12 6 18-3t2-15q-12-12-46-80-14-10-12-35t40-53q58-40 96 22 6 12 26 41t22 33q4 10 13 9t11-17z m-858-684l254 248 76-86-246-242q-20-20-38-4l-46 46q-22 18 0 38z" horiz-adv-x="1000" />
+
+<glyph glyph-name="cogs" unicode="&#x26ef;" d="M0 245l0 97 94 8q8 30 23 55l-60 74 68 69 74-61q26 16 55 23l8 94 97 0 10-94q29-7 55-23l74 61 68-69-60-74q16-25 23-55l94-8 0-97-94-10q-7-29-23-55l60-72-68-70-74 60q-26-15-55-23l-10-94-97 0-8 94q-29 8-55 23l-74-60-68 70 60 72q-15 26-23 55z m221 49q0-37 26-64t64-26 63 26 26 64-26 63-63 26-64-26-26-63z m318 238l8 72 70-2q8 22 20 39l-37 57 54 45 49-49q20 10 41 14l14 66 72-8-2-68q22-8 39-22l57 39 45-54-49-49q10-20 12-43l68-14-8-70-68 0q-8-20-22-37l39-59-56-45-47 49q-22-8-43-12l-14-66-70 6 0 70q-20 8-37 20l-59-37-45 54 49 49q-8 20-12 41z m31-445l6 50 49 0q6 16 14 28l-26 43 37 33 36-37q13 8 29 10l10 48 48-5 0-49q16-6 28-16l41 27 31-41-35-35q6-13 10-29l47-12-6-51-49 0q-4-15-14-27l28-43-40-33-35 37q-13-8-29-10l-10-49-49 6 0 51q-13 4-27 14l-41-28-31 41 35 36q-6 13-8 29z m118 13q-4-21 8-36t32-17 34 9 17 34-10 35-31 18l-6 0q-17 0-31-12t-13-31z m17 451q-4-27 14-48t45-25 48 15 23 45-14 48-44 24l-7 0q-26 0-44-17t-21-42z" horiz-adv-x="1000" />
+
+<glyph glyph-name="mail" unicode="&#x2709;" d="M30 586q-32 18-28 40 2 14 26 14l846 0q38 0 20-32-8-14-24-22-14-6-192-102t-182-98q-16-10-46-10-28 0-46 10-4 2-182 98t-192 102z m850-100q20 10 20-10l0-368q0-16-17-32t-33-16l-800 0q-16 0-33 16t-17 32l0 368q0 20 20 10l384-200q18-10 46-10t46 10z" horiz-adv-x="900" />
+
+<glyph glyph-name="user-add" unicode="&#xe700;" d="M620 128q180-64 180-122l0-106-800 0 0 202q36 14 82 26 94 34 129 69t35 95q0 22-23 48t-31 74q-2 12-23 25t-25 61q0 16 5 26t9 12l4 4q-8 50-12 88-6 54 40 112t160 58 160-58 42-112l-14-88q18-8 18-42-2-28-9-43t-14-17-14-8-9-18q-10-46-33-73t-23-49q0-60 36-95t130-69z m230 272l150 0 0-100-150 0 0-150-100 0 0 150-150 0 0 100 150 0 0 150 100 0 0-150z" horiz-adv-x="1000" />
+
+<glyph glyph-name="vcard" unicode="&#xe722;" d="M900 750q42 0 71-29t29-71l0-600q0-40-29-70t-71-30l-800 0q-40 0-70 30t-30 70l0 600q0 42 30 71t70 29l800 0z m0-700l0 600-800 0 0-600 800 0z m-450 196l0-90-250 0 0 90 250 0z m0 150l0-90-250 0 0 90 250 0z m0 150l0-90-250 0 0 90 250 0z m346-320l4-70-250 0q0 70 6 70 84 22 84 66 0 16-27 56t-27 88q0 110 90 110t90-110q0-48-28-88t-28-56q0-20 21-36t43-22z" horiz-adv-x="1000" />
+
+<glyph glyph-name="archive" unicode="&#xe738;" d="M840 600l0-50-696 0 0 50q0 22 13 35t25 15l608 0q6 0 14-1t22-14 14-35z m-148 150q6 0 14-1t22-14 14-35l-498 0q0 22 13 35t25 15l410 0z m248-200q34-32 38-46 6-18 0-54l-76-450q-4-22-20-35t-28-15l-710 0q-52 0-60 50-6 26-39 223t-39 227q-10 22-3 44t10 26 21 20l10 10 30 30 0-80 836 0 0 80z m-248-270l0 100-70 0 0-80-260 0 0 80-68 0 0-100q0-50 48-50l300 0q22 0 35 12t13 24z" horiz-adv-x="981" />
+
+<glyph glyph-name="share" unicode="&#xe73c;" d="M650 200q62 0 106-43t44-107q0-62-44-106t-106-44-106 44-44 106q0 6 1 14t1 12l-260 156q-42-32-92-32-62 0-106 44t-44 106 44 106 106 44q54 0 92-30l260 156q0 4-1 12t-1 12q0 62 44 106t106 44 106-43 44-107q0-62-44-106t-106-44q-52 0-90 32l-262-156q2-8 2-26 0-16-2-24l262-156q36 30 90 30z" horiz-adv-x="800" />
+
+<glyph glyph-name="back-in-time" unicode="&#xe771;" d="M532 760q170 0 289-120t119-290-119-290-289-120q-138 0-252 88l70 76q82-60 182-60 126 0 216 90t90 216q0 128-90 218t-216 90q-124 0-213-86t-93-210l142 0-184-206-184 206 124 0q4 166 123 282t285 116z m-36-190l70 0 0-204 130-130-50-50-150 150 0 234z" horiz-adv-x="940" />
+
+<glyph glyph-name="off" unicode="&#xe78e;" d="M857 350q0-87-34-166t-91-137-137-92-166-34-167 34-136 92-92 137-34 166q0 102 45 191t126 151q24 18 54 14t46-28q18-23 14-53t-28-47q-54-41-84-101t-30-127q0-58 23-111t61-91 91-61 111-23 110 23 92 61 61 91 22 111q0 68-30 127t-84 101q-23 18-28 47t14 53q17 24 47 28t53-14q81-61 126-151t45-191z m-357 429v-358q0-29-21-50t-50-21-51 21-21 50v358q0 29 21 50t51 21 50-21 21-50z" horiz-adv-x="857.1" />
+
+<glyph glyph-name="flow-tree" unicode="&#xe792;" d="M868 112q72-34 72-112 0-50-35-85t-85-35-85 35-35 85q0 78 72 112l0 114q0 78-76 78l-100 0q-44 0-78 12l0-204q72-34 72-112 0-50-35-85t-85-35-85 35-35 85q0 78 72 112l0 204q-30-12-76-12l-100 0q-34 0-53-19t-22-33-3-26l0-114q72-34 72-112 0-50-35-85t-85-35-85 35-35 85q0 78 72 112l0 114q0 64 43 118t131 54l100 0q76 0 76 52l0 140q-72 34-72 110 0 50 35 85t85 35 85-35 35-85q0-76-72-110l0-140q0-52 78-52l100 0q86 0 129-54t43-118l0-114z m-678-112q0 30-21 50t-49 20-48-20-20-50q0-28 20-48t48-20 49 20 21 48z m212 700q0-28 20-48t48-20 49 20 21 48q0 30-21 50t-49 20-48-20-20-50z m138-700q0 30-21 50t-49 20-48-20-20-50q0-28 20-48t48-20 49 20 21 48z m280-68q28 0 49 20t21 48q0 30-21 50t-49 20-48-20-20-50q0-28 20-48t48-20z" horiz-adv-x="940" />
+
+<glyph glyph-name="paper-plane" unicode="&#xe79b;" d="M894 720q14 4 22-3t4-19q-2-6-72-310t-74-316q-2-14-14-19t-24 1l-248 134-30 16 22 26q388 420 394 426 4 4-1 9t-9 1l-550-402-112 44-190 76q-12 4-12 12t12 12q8 4 441 157t441 155z m-582-728l0 204 160-82q-130-116-142-128-18-14-18 6z" horiz-adv-x="921" />
+
+<glyph glyph-name="traffic-cone" unicode="&#xe7a3;" d="M480 246q-90 0-156 27t-70 65q44 124 56 158 10-28 59-46t111-18q64 0 112 18t58 46q12-34 56-158-4-38-70-65t-156-27z m0 334q-96 0-124 44l52 142q10 34 72 34t74-34q12-38 50-142-28-44-124-44z m440-384q40-16 41-41t-37-47l-352-188q-38-22-91-22t-91 22l-354 188q-38 20-36 46t42 42l188 76-22-60q0-48 80-82t192-34 192 34 82 82l-22 60z" horiz-adv-x="961" />
+
+<glyph glyph-name="github" unicode="&#xf09b;" d="M0 350q0 207 147 354t353 146 354-146 146-354-146-354-354-146-353 146-147 354z m55 0q0-183 131-314t314-131 314 131 131 314-131 315-314 130-314-130-131-315z m140-107q-4 4 0 7 8 8 20 6t14-4q13-5 30-26t26-28q47-39 96-6 4 14 13 23t16 14 28 12q-65 6-105 22t-63 42q-32 35-39 92t9 103q12 30 33 53-15 49 6 112 63-4 114-43 97 25 203 2 13 9 42 24t71 17q8-22 11-51t-3-55q45-47 47-125 0-62-22-103t-76-69q-37-17-98-21 28-14 40-29t17-45l0-62t2-60q6-9 14-16t13-10 4-9-15-6q-34 0-53 24-6 10-6 23l0 94q0 16-8 22t-15 9l0-123q0-33 7-43t10-19q2-2-3-3t-16 3q-26 5-36 26t-9 44l0 121-26 0 0-121q0-23-9-45-14-27-51-29-6 2-6 4 2 2 12 19 2 4 4 16t3 27l0 123q-7-2-14-9t-7-22l0-94q0-13-6-23-17-24-53-24-13 0-15 6-2 4 1 7t8 7 8 6q8 6 12 15 6 8 3 41t-1 43q-51-17-104 18-15 16-31 45-11 21-47 51z" horiz-adv-x="1000" />
+
+<glyph glyph-name="lightbulb" unicode="&#xf0eb;" d="M390 663q64 0 109-45t46-110q0-11-7-18t-19-8-19 8-7 18q0 43-30 73t-73 30q-25 0-25 26t25 26z m0-860q-21 0-36 16t-15 37l-104 0q-21 0-37 15t-15 37l0 157q0 31-23 76t-90 142q-70 97-70 224 0 161 114 276t276 114 276-114 115-276q0-121-71-224-67-96-89-140t-22-78l0-157q0-21-15-37t-36-15l-105 0q0-22-16-37t-37-16z m105 156l0 53-209 0 0-53 209 0z m130 383q53 76 53 165 0 118-85 202t-203 84q-117 0-202-84t-84-202q0-87 52-164 107-155 124-227l59 0 0 105q0 21 15 36t36 15 37-15 16-36l0-105 57 0q16 71 125 226z" horiz-adv-x="781" />
+
+<glyph glyph-name="twitter" unicode="&#xf309;" d="M0 26q162-19 305 88-67-2-117 39t-77 105q18-3 43-1 34 1 49 5-99 34-139 108-23 51-23 103 57-29 92-27-80 68-90 146-6 63 25 135 114-125 239-180 93-39 185-39-12 94 26 159 45 70 128 93 55 14 106-3t86-55q60 6 133 49-30-86-92-118 62 10 121 36-10-20-37-53t-68-57q1-10 1-17 4-110-42-233-81-201-243-302-160-90-382-69-141 16-229 88z" horiz-adv-x="1000" />
+
+<glyph glyph-name="box" unicode="&#xf330;" d="M286 806l194-158-284-184-196 164z m368-754q8 0 12 2l116 78 0-46-302-190-302 190 0 46 118-78q4-2 12-2 10 0 14 4l158 132 160-132q4-4 14-4z m306 576l-194-164-286 184 196 158z m-480-342l286 178 174-140-282-184z m-176-146l-282 184 174 140 284-178z" horiz-adv-x="960" />
+
+<glyph glyph-name="leaf" unicode="&#x1f342;" d="M236 646q182 106 506 66 168-22 196-50 4-6-2-10-76-40-130-109t-78-132-65-132-93-105q-138-96-382-4-66-76-114-176-12-24-47-7t-25 39q44 100 129 193t176 153 176 106 141 68l54 20q-14 0-41-1t-104-14-148-38-162-84-161-141q-22 242 174 358z" horiz-adv-x="940" />
+
+<glyph glyph-name="user" unicode="&#x1f464;" d="M736 128q204-72 204-122l0-106-940 0 0 106q0 50 204 122 94 34 128 69t34 95q0 22-22 49t-32 73q-2 12-9 18t-14 8-14 17-9 43q0 16 5 26t9 12l4 4q-8 50-12 88-4 54 41 112t157 58 158-58 40-112l-12-88q18-8 18-42-2-28-9-43t-14-17-14-8-9-18q-8-48-31-74t-23-48q0-60 35-95t127-69z" horiz-adv-x="940" />
+
+<glyph glyph-name="users" unicode="&#x1f465;" d="M1000-90l-224 0 0 150q0 54-30 81t-154 89q40 30 40 84 0 16-13 33t-19 51q-2 8-14 16t-14 42q0 24 12 30-6 34-8 60-4 38 23 78t95 40 96-40 24-78l-8-60q12-6 12-30-2-34-14-42t-14-16q-6-34-19-51t-13-33q0-42 21-66t77-48q112-46 130-80 6-8 9-61t5-101l0-48z m-488 262q182-78 182-124l0-138-694 0 0 184q0 44 84 78 76 32 104 64t28 88q0 20-19 44t-25 68q-2 10-18 22t-20 56q0 14 3 23t7 13l4 2q-6 46-10 82-4 50 33 103t127 53 127-53 33-103l-10-82q14-8 14-38-4-44-20-56t-18-22q-6-44-25-68t-19-44q0-56 28-88t104-64z" horiz-adv-x="1000" />
+
+<glyph glyph-name="folder" unicode="&#x1f4c1;" d="M954 500q32 0 40-12t6-36l-42-452q-2-24-12-37t-42-13l-806 0q-52 0-56 50l-42 452q-2 24 6 36t40 12l908 0z m-34 110l10-40-846 0 14 132q4 20 20 34t36 14l164 0q52 0 86-34l30-30q32-36 86-36l340 0q20 0 38-12t22-28z" horiz-adv-x="1001" />
+
+<glyph glyph-name="chart" unicode="&#x1f4c8;" d="M34 284q-42 10-32 56 10 42 54 32l98-24-52-80z m890-12q14 12 33 11t31-15q32-32-2-64l-252-226q-12-12-30-12-14 0-28 10l-286 220-54 14 50 80 36-8q12-4 16-8l264-204z m-490 220l-350-550q-12-22-38-22-12 0-24 8-16 10-20 29t6 33l374 588q8 16 28 20 18 6 36-6l246-156 226 326q10 16 28 19t34-9q38-24 12-62l-252-362q-24-36-62-12z" horiz-adv-x="1003" />
+
+<glyph glyph-name="megaphone" unicode="&#x1f4e3;" d="M792 500q58-138 67-258t-39-140q-28-12-61 3t-65 40-99 41-149 8q-28-4-42-19t-6-37q22-56 46-108 4-10 24-22t24-20q14-34-22-46-50-22-102-40-30-10-54 42-32 76-58 132-6 12-34 17t-46 31q-30-10-38-14-34-12-74 12t-54 60q-17 32-5 79t43 61q126 52 213 108t124 103 59 92 25 78 15 59 36 36q48 20 130-70t142-228z m-28-300q8 4 10 38t-11 98-41 128q-28 66-67 123t-67 84-36 23-10-42 10-105 40-133 68-119 68-76 36-19z" horiz-adv-x="860" />
+
+<glyph glyph-name="newspaper" unicode="&#x1f4f0;" d="M700 800q42 0 71-29t29-71l0-700q0-40-29-70t-71-30l-600 0q-40 0-70 30t-30 70l0 700q0 42 30 71t70 29l600 0z m0-800l0 700-600 0 0-700 600 0z m-250 250l0-50-250 0 0 50 250 0z m150 200l0-50-200 0 0 50 200 0z m-200 50l0 100 200 0 0-100-200 0z m-50 100l0-200-150 0 0 200 150 0z m-50-250l0-50-100 0 0 50 100 0z m50-50l0 50 250 0 0-50-250 0z m250-150l0-50-400 0 0 50 400 0z m-100 50l0 50 100 0 0-50-100 0z" horiz-adv-x="800" />
+
+<glyph glyph-name="mobile" unicode="&#x1f4f1;" d="M480 840q42 0 71-29t29-71l0-780q0-40-29-70t-71-30l-380 0q-40 0-70 30t-30 70l0 780q0 42 30 71t70 29l380 0z m-190-940q30 0 50 15t20 35q0 22-20 36t-50 14q-28 0-49-15t-21-35 21-35 49-15z m210 150l0 660-420 0 0-660 420 0z" horiz-adv-x="580" />
+
+<glyph glyph-name="key" unicode="&#x1f511;" d="M774 612q20-116-28-215t-150-117q-66-12-130-2l-118-194-70-12-104-166q-14-28-46-32l-76-14q-12-4-22 4t-12 22l-16 98q-8 30 12 56l258 386q-24 50-38 120-18 106 53 187t185 101q106 20 195-45t107-177z m-126-76q30 44 21 97t-51 83q-42 32-92 22t-80-54q-8-12-12-23t-1-20 5-16 13-17 18-15 22-16 23-17q6-4 22-16t23-16 19-12 19-8 17 1 18 8 16 19z" horiz-adv-x="780" />
+
+<glyph glyph-name="lock" unicode="&#x1f512;" d="M640 476q20 0 40-19t20-41l0-390q0-48-48-66l-60-18q-42-16-96-16l-290 0q-56 0-98 16l-60 18q-48 18-48 66l0 390q0 22 15 41t35 19l100 0 0 70q0 110 51 170t149 60 149-60 51-170l0-70 90 0z m-390 90l0-90 200 0 0 90q0 52-27 81t-73 29-73-29-27-81z" horiz-adv-x="700" />
+
+<glyph glyph-name="lock-open" unicode="&#x1f513;" d="M640 450q20 0 40-20t20-40l0-390q0-20-14-39t-34-25l-60-20q-52-16-96-16l-290 0q-46 0-98 16l-60 20q-20 6-34 25t-14 39l0 390q0 22 15 41t35 19l400 0 0 140q0 110-100 110t-100-110l0-40-100 0 0 20q0 110 51 170t149 60q200 0 200-230l0-120 90 0z" horiz-adv-x="700" />
+
+<glyph glyph-name="rocket" unicode="&#x1f680;" d="M543 236q6-50 8-81t-8-59-13-40-35-32-45-26-70-31-85-37q-32-12-45 4t-3 44l40 110-130 132-106-40q-28-12-43 2t-3 46q12 30 31 79t27 65 22 45 25 36 29 20 41 13l52 0t71-6q10 14 29 39t77 85 118 104 145 75 165 19q8 0 14-6 4-4 6-14 10-82-18-168t-76-151-98-118-86-81z m50 296q22-22 54-22t54 22q22 24 22 56t-22 56q-22 22-54 22t-54-22q-22-24-22-56t22-56z" horiz-adv-x="860" />
+</font>
+</defs>
+</svg>

BIN
src/Packagist/WebBundle/Resources/public/font/fontello.ttf


BIN
src/Packagist/WebBundle/Resources/public/font/fontello.woff


+ 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
 

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

@@ -0,0 +1,12 @@
+{% autoescape false -%}
+
+Two-factor authentication has been disabled on your Packagist account.
+
+-------------------------------
+Time: {{ 'now'|date('c') }}
+Reason: {{ reason }}
+-------------------------------
+
+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 %}

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

@@ -0,0 +1,56 @@
+{% extends "PackagistWebBundle:user:packages.html.twig" %}
+
+{% import "PackagistWebBundle::macros.html.twig" as macros %}
+
+{% block content %}
+    {% set isActualUser = app.user and app.user.username is same as(user.username) %}
+
+    {% if backup_code %}
+        <div class="alert alert-info">
+            <p>If you lose your phone or otherwise can't get codes via your authenticator app, you can this one-time backup code to sign in: <code>{{ backup_code }}</code></p>
+            <p><strong>Take a moment to record this in a safe place. This code will not be shown again!</strong></p>
+        </div>
+    {% endif %}
+
+    <h2 class="title">
+        {{ user.username }}
+        {%- if not isActualUser %}
+            <small>
+                member since: {{ user.createdAt|date('M d, Y') }}
+                {%- if is_granted('ROLE_ADMIN') %}
+                    <a href="mailto:{{ user.email }}">{{ user.email }}</a>
+                {%- endif %}
+            </small>
+        {%- endif %}
+    </h2>
+
+    <section class="row">
+        {% if isActualUser %}
+            <section class="col-md-3">
+                {{ knp_menu_render('profile_menu', {currentClass: 'active', allow_safe_labels: true}) }}
+            </section>
+        {% endif %}
+
+        <section class="{{ isActualUser ? 'col-md-9' : 'col-md-12' }}">
+            {% 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="text-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 %}

+ 53 - 0
src/Packagist/WebBundle/Resources/views/user/confirm_two_factor_auth.html.twig

@@ -0,0 +1,53 @@
+{% extends "PackagistWebBundle:user:packages.html.twig" %}
+
+{% import "PackagistWebBundle::macros.html.twig" as macros %}
+
+{% block content %}
+    {% set isActualUser = app.user and app.user.username is same as(user.username) %}
+
+    <h2 class="title">
+        {{ user.username }}
+        {%- if not isActualUser %}
+            <small>
+                member since: {{ user.createdAt|date('M d, Y') }}
+                {%- if is_granted('ROLE_ADMIN') %}
+                    <a href="mailto:{{ user.email }}">{{ user.email }}</a>
+                {%- endif %}
+            </small>
+        {%- endif %}
+    </h2>
+
+    <section class="row">
+        {% if isActualUser %}
+            <section class="col-md-3">
+                {{ knp_menu_render('profile_menu', {currentClass: 'active', allow_safe_labels: true}) }}
+            </section>
+        {% endif %}
+
+        <section class="col-md-6">
+            <h3>Save your backup code</h3>
+
+            <div class="well well-lg">
+                <p>A backup code will let you access your account if your phone is lost, stolen, or you otherwise can't generate codes via your authenticator app.  We ask that you save this unique, one-time use backup code in a safe place:</p>
+
+                <kbd class="two-factor-backup-code">{{ backup_code }}</kbd>
+
+                <p class="text-danger"><strong>Without access to your authenticator app or backup code, you will permanently lose access to your account.</strong></p>
+
+                <p>To use the backup code, simply enter it during two-factor authentication where you'd normally enter a generated code.</p>
+
+                <p>
+                    Please take a moment to put this code in a safe place.
+                    <strong>This code will not be shown again!</strong>
+                </p>
+            </div>
+
+            <p>
+                <a href="{{path('user_2fa_configure', {name: user.username})}}" class="btn btn-success btn-lg">
+                    <span class="icon-check"></span>
+                    I have saved the backup code
+                </a>
+            </p>
+        </section>
+    </section>
+{% endblock %}

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

@@ -0,0 +1,49 @@
+{% extends "PackagistWebBundle:user:packages.html.twig" %}
+
+{% import "PackagistWebBundle::macros.html.twig" as macros %}
+
+{% block content %}
+    {% set isActualUser = app.user and app.user.username is same as(user.username) %}
+
+    <h2 class="title">
+        {{ user.username }}
+        {%- if not isActualUser %}
+            <small>
+                member since: {{ user.createdAt|date('M d, Y') }}
+                {%- if is_granted('ROLE_ADMIN') %}
+                    <a href="mailto:{{ user.email }}">{{ user.email }}</a>
+                {%- endif %}
+            </small>
+        {%- endif %}
+    </h2>
+
+    <section class="row">
+        {% if isActualUser %}
+            <section class="col-md-3">
+                {{ knp_menu_render('profile_menu', {currentClass: 'active', allow_safe_labels: true}) }}
+            </section>
+        {% endif %}
+
+        <section class="{{ isActualUser ? 'col-md-9' : 'col-md-12' }}">
+            <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 %}

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

@@ -0,0 +1,71 @@
+{% extends "PackagistWebBundle:user:packages.html.twig" %}
+
+{% import "PackagistWebBundle::macros.html.twig" as macros %}
+
+{% block content %}
+    {% set isActualUser = app.user and app.user.username is same as(user.username) %}
+
+    <h2 class="title">
+        {{ user.username }}
+        {%- if not isActualUser %}
+            <small>
+                member since: {{ user.createdAt|date('M d, Y') }}
+                {%- if is_granted('ROLE_ADMIN') %}
+                    <a href="mailto:{{ user.email }}">{{ user.email }}</a>
+                {%- endif %}
+            </small>
+        {%- endif %}
+    </h2>
+
+    <section class="row">
+        {% if isActualUser %}
+            <section class="col-md-3">
+                {{ knp_menu_render('profile_menu', {currentClass: 'active', allow_safe_labels: true}) }}
+            </section>
+        {% endif %}
+
+        <section class="{{ isActualUser ? 'col-md-9' : 'col-md-12' }}">
+            <p>
+                To enable two-factor authentication, you'll need an app that supports TOTP such as
+                <a href="https://authy.com/download/">Authy</a>,
+                <a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a>,
+                or
+                <a href="https://keepassxc.org/">KeePassXC</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>
+
+            <p class="pull-right two-factor-key">
+                <img src="{{ qr_code_data_uri(provisioningUri) }}" height="200" />
+                <br>
+                <small>TOTP Key: <code>{{ secret }}</code></small>
+            </p>
+
+            <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 %}

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

@@ -0,0 +1,154 @@
+<?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 Scheb\TwoFactorBundle\Model\BackupCodeInterface;
+use Scheb\TwoFactorBundle\Security\TwoFactor\Backup\BackupCodeManagerInterface;
+use Symfony\Bridge\Doctrine\RegistryInterface;
+use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
+use Twig\Environment;
+
+/**
+ * @author Colin O'Dell <colinodell@gmail.com>
+ */
+class TwoFactorAuthManager implements BackupCodeManagerInterface
+{
+    protected $doctrine;
+    protected $mailer;
+    protected $twig;
+    protected $logger;
+    protected $flashBag;
+    protected $options;
+
+    public function __construct(RegistryInterface $doctrine, \Swift_Mailer $mailer, Environment $twig, LoggerInterface $logger, FlashBagInterface $flashBag, array $options)
+    {
+        $this->doctrine = $doctrine;
+        $this->mailer = $mailer;
+        $this->twig = $twig;
+        $this->logger = $logger;
+        $this->flashBag = $flashBag;
+        $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
+     * @param string $reason
+     */
+    public function disableTwoFactorAuth(User $user, string $reason)
+    {
+        $user->setTotpSecret(null);
+        $user->invalidateAllBackupCodes();
+        $this->doctrine->getManager()->flush();
+
+        $body = $this->twig->render('PackagistWebBundle:email:two_factor_disabled.txt.twig', array(
+            'username' => $user->getUsername(),
+            'reason' => $reason,
+        ));
+
+        $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());
+        }
+    }
+
+    /**
+     * Generate a new backup code and save it on the given user account.
+     *
+     * @param User $user
+     *
+     * @return string
+     */
+    public function generateAndSaveNewBackupCode(User $user): string
+    {
+        $code = bin2hex(random_bytes(4));
+        $user->setBackupCode($code);
+
+        $this->doctrine->getManager()->flush();
+
+        return $code;
+    }
+
+    /**
+     * Check if the code is a valid backup code of the user.
+     *
+     * @param User   $user
+     * @param string $code
+     *
+     * @return bool
+     */
+    public function isBackupCode($user, string $code): bool
+    {
+        if ($user instanceof BackupCodeInterface) {
+            return $user->isBackupCode($code);
+        }
+
+        return false;
+    }
+
+    /**
+     * Invalidate a backup code from a user.
+     *
+     * This should only be called after the backup code has been confirmed and consumed.
+     *
+     * @param User   $user
+     * @param string $code
+     */
+    public function invalidateBackupCode($user, string $code): void
+    {
+        if ($user instanceof BackupCodeInterface) {
+            $this->disableTwoFactorAuth($user, 'Backup code used');
+            $this->flashBag->add('warning', 'Use of your backup code has disabled two-factor authentication for your account. Please consider re-enabling it for your security.');
+        }
+    }
+}

+ 67 - 0
src/Packagist/WebBundle/Security/TwoFactorAuthRateLimiter.php

@@ -0,0 +1,67 @@
+<?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 Predis\Client;
+use Scheb\TwoFactorBundle\Security\TwoFactor\Event\TwoFactorAuthenticationEvent;
+use Scheb\TwoFactorBundle\Security\TwoFactor\Event\TwoFactorAuthenticationEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
+
+/**
+ * @author Colin O'Dell <colinodell@gmail.com>
+ */
+class TwoFactorAuthRateLimiter implements EventSubscriberInterface
+{
+    const MAX_ATTEMPTS = 5;
+    const RATE_LIMIT_DURATION = 15; // in minutes
+
+    /** @var Client */
+    protected $redis;
+
+    public function __construct(Client $redis)
+    {
+        $this->redis = $redis;
+    }
+
+    public function onAuthAttempt(TwoFactorAuthenticationEvent $event)
+    {
+        $key = '2fa-failures:'.$event->getToken()->getUsername();
+        $count = (int)$this->redis->get($key);
+
+        if ($count >= self::MAX_ATTEMPTS) {
+            throw new CustomUserMessageAuthenticationException(sprintf('Too many authentication failures. Try again in %d minutes.', self::RATE_LIMIT_DURATION));
+        }
+    }
+
+    public function onAuthFailure(TwoFactorAuthenticationEvent $event)
+    {
+        $key = '2fa-failures:'.$event->getToken()->getUsername();
+
+        $this->redis->multi();
+        $this->redis->incr($key);
+        $this->redis->expire($key, self::RATE_LIMIT_DURATION * 60);
+        $this->redis->exec();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public static function getSubscribedEvents()
+    {
+        return [
+            TwoFactorAuthenticationEvents::FAILURE => 'onAuthFailure',
+            TwoFactorAuthenticationEvents::ATTEMPT => 'onAuthAttempt',
+        ];
+    }
+}