From 305599c8cb70832d651f90cbf4086dbb3c52b750 Mon Sep 17 00:00:00 2001 From: flashwave Date: Sun, 10 Mar 2019 01:57:19 +0100 Subject: [PATCH] Added time based two factor authentication support. --- assets/less/classes/settings/two-factor.less | 43 ++++++++ assets/less/main.less | 1 + composer.json | 5 +- composer.lock | 104 +++++++++++++++++- .../2019_03_09_222347_add_two_factor_auth.php | 20 ++++ misuzu.php | 2 + public/auth/login.php | 49 +++++++-- public/settings.php | 90 +++++++++++---- src/Users/user.php | 2 +- src/base32.php | 39 +++++++ src/otp.php | 72 ++++++++++++ templates/auth/twofactor.twig | 40 +++++++ templates/user/settings.twig | 41 ++++++- 13 files changed, 468 insertions(+), 40 deletions(-) create mode 100644 assets/less/classes/settings/two-factor.less create mode 100644 database/2019_03_09_222347_add_two_factor_auth.php create mode 100644 src/base32.php create mode 100644 src/otp.php create mode 100644 templates/auth/twofactor.twig diff --git a/assets/less/classes/settings/two-factor.less b/assets/less/classes/settings/two-factor.less new file mode 100644 index 00000000..ae256f0c --- /dev/null +++ b/assets/less/classes/settings/two-factor.less @@ -0,0 +1,43 @@ +.settings__two-factor { + display: flex; + margin: 5px; + + @media (max-width: @site-mobile-width) { + flex-direction: column; + align-items: center; + } + + &__code { + display: flex; + flex-direction: column; + align-items: center; + background-color: #fff; + flex: 0 0 auto; + + &__image { + vertical-align: middle; + flex: 0 0 auto; + } + + &__text { + color: #000; + flex: 0 0 auto; + font-size: 1.2em; + line-height: 1.5em; + font-family: @mio-font-mono; + } + } + + &__settings { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + flex: 1 1 auto; + + &__status { + font-size: 1.5em; + line-height: 2em; + } + } +} diff --git a/assets/less/main.less b/assets/less/main.less index fc392762..140b5ea8 100644 --- a/assets/less/main.less +++ b/assets/less/main.less @@ -184,6 +184,7 @@ html { @import "classes/settings/session"; @import "classes/settings/sessions"; @import "classes/settings/role"; +@import "classes/settings/two-factor"; // News @import "classes/news/container"; diff --git a/composer.json b/composer.json index 2c3dd369..a98ba489 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "issues": "https://github.com/flashwave/misuzu/issues" }, "require": { - "php": ">=7.2", + "php": "^7.2", "ext-bcmath": "*", "ext-mbstring": "*", "twig/twig": "~2.4", @@ -16,7 +16,8 @@ "geoip2/geoip2": "~2.0", "twig/extensions": "^1.5", "filp/whoops": "^2.2", - "jublonet/codebird-php": "^3.1" + "jublonet/codebird-php": "^3.1", + "chillerlan/php-qrcode": "^3.0" }, "autoload": { "classmap": [ diff --git a/composer.lock b/composer.lock index e8351c04..08cbcbe2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,110 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3d0e94350450c6e4148bf7a0ea7ce697", + "content-hash": "6e8574e32141af1cd3300c2fee5a1e4b", "packages": [ + { + "name": "chillerlan/php-qrcode", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-qrcode.git", + "reference": "ba81dab259d687d246295e6998533c314367847a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/ba81dab259d687d246295e6998533c314367847a", + "reference": "ba81dab259d687d246295e6998533c314367847a", + "shasum": "" + }, + "require": { + "chillerlan/php-settings-container": "^1.0", + "php": "^7.2" + }, + "require-dev": { + "phpunit/phpunit": "^7.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-v2.0.x-php5": "1.0.8-dev" + } + }, + "autoload": { + "psr-4": { + "chillerlan\\QRCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kazuhiko Arase", + "homepage": "https://github.com/kazuhikoarase" + }, + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A QR code generator. PHP 7.2+", + "homepage": "https://github.com/chillerlan/php-qrcode", + "keywords": [ + "qr code" + ], + "time": "2018-12-20T15:37:46+00:00" + }, + { + "name": "chillerlan/php-settings-container", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-settings-container.git", + "reference": "d7a555304a3e687db1de1b8b0a6b4196ba9957da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/d7a555304a3e687db1de1b8b0a6b4196ba9957da", + "reference": "d7a555304a3e687db1de1b8b0a6b4196ba9957da", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\Settings\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A container class for immutable settings objects. Not a DI container. PHP 7.2+", + "homepage": "https://github.com/chillerlan/php-settings-container", + "keywords": [ + "PHP7", + "Settings", + "container", + "helper" + ], + "time": "2019-03-01T15:54:49+00:00" + }, { "name": "composer/ca-bundle", "version": "1.1.4", diff --git a/database/2019_03_09_222347_add_two_factor_auth.php b/database/2019_03_09_222347_add_two_factor_auth.php new file mode 100644 index 00000000..8ee25699 --- /dev/null +++ b/database/2019_03_09_222347_add_two_factor_auth.php @@ -0,0 +1,20 @@ +exec(" + ALTER TABLE `msz_users` + ADD COLUMN `user_totp_key` CHAR(26) NULL DEFAULT NULL AFTER `display_role`; + "); +} + +function migrate_down(PDO $conn): void +{ + $conn->exec(" + ALTER TABLE `msz_users` + DROP COLUMN `user_totp_key`; + "); +} diff --git a/misuzu.php b/misuzu.php index 9e3ecc80..a8a9c4e4 100644 --- a/misuzu.php +++ b/misuzu.php @@ -28,6 +28,7 @@ $errorHandler->register(); require_once 'src/array.php'; require_once 'src/audit_log.php'; +require_once 'src/base32.php'; require_once 'src/changelog.php'; require_once 'src/colour.php'; require_once 'src/comments.php'; @@ -40,6 +41,7 @@ require_once 'src/integer.php'; require_once 'src/mail.php'; require_once 'src/manage.php'; require_once 'src/news.php'; +require_once 'src/otp.php'; require_once 'src/pagination.php'; require_once 'src/perms.php'; require_once 'src/string.php'; diff --git a/public/auth/login.php b/public/auth/login.php index 3fecff96..ec6d580a 100644 --- a/public/auth/login.php +++ b/public/auth/login.php @@ -28,6 +28,7 @@ while (!empty($login->value('array'))) { } $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ''; + $loginRedirect = $login->redirect->value('string', ''); if ($login->username->empty() || $login->password->empty()) { $notices[] = "You didn't fill in a username and/or password."; @@ -39,12 +40,14 @@ while (!empty($login->value('array'))) { break; } - $userData = user_find_for_login($login->username->value('string', '')); - $loginFailedError = sprintf( - "Invalid username or password, %d attempt%s remaining.", + $loginUsername = $login->username->value('string', ''); + $userData = user_find_for_login($loginUsername); + $attemptsRemainingError = sprintf( + "%d attempt%s remaining", $remainingAttempts - 1, $remainingAttempts === 2 ? '' : 's' ); + $loginFailedError = "Invalid username or password, {$attemptsRemainingError}."; if (empty($userData) || $userData['user_id'] < 1) { user_login_attempt_record(false, null, $ipAddress, $userAgent); @@ -52,19 +55,45 @@ while (!empty($login->value('array'))) { break; } - if (!password_verify($login->password->value('string', ''), $userData['password'])) { + $loginPassword = $login->password->value('string', ''); + + if (isset($login->tfa)) { + $loginPassword = openssl_decrypt($loginPassword, 'aes-256-ecb', config_get_default('insecure', 'Auth', 'password_key')); + } + + if (!password_verify($loginPassword, $userData['password'])) { user_login_attempt_record(false, $userData['user_id'], $ipAddress, $userAgent); $notices[] = $loginFailedError; break; } - user_login_attempt_record(true, $userData['user_id'], $ipAddress, $userAgent); - if ($loginPermission > 0 && !perms_check_user(MSZ_PERMS_GENERAL, $userData['user_id'], $loginPermission)) { $notices[] = "Login succeeded, but you're not allowed to browse the site right now."; + user_login_attempt_record(true, $userData['user_id'], $ipAddress, $userAgent); break; } + if (!empty($userData['user_totp_key'])) { + $currentCode = totp_generate($userData['user_totp_key']); + + if (isset($login->tfa) && $currentCode !== $login->tfa->value('string', '')) { + $notices[] = "Invalid two factor code, {$attemptsRemainingError}."; + user_login_attempt_record(false, $userData['user_id'], $ipAddress, $userAgent); + } + + if (!isset($login->tfa) || !empty($notices)) { + echo tpl_render('auth.twofactor', [ + 'login_notices' => $notices, + 'login_username' => $loginUsername, + 'login_redirect' => $loginRedirect, + // red flags, this should be fine but probably replaced with a token system going forward + 'login_password' => openssl_encrypt($loginPassword, 'aes-256-ecb', config_get_default('insecure', 'Auth', 'password_key')), + ]); + return; + } + } + + user_login_attempt_record(true, $userData['user_id'], $ipAddress, $userAgent); $sessionKey = user_session_create($userData['user_id'], $ipAddress, $userAgent); if (empty($sessionKey)) { @@ -77,13 +106,11 @@ while (!empty($login->value('array'))) { $cookieValue = base64url_encode(user_session_cookie_pack($userData['user_id'], $sessionKey)); setcookie('msz_auth', $cookieValue, $cookieLife, '/', '', true, true); - $redirect = $login->redirect->value('string', ''); - - if (!is_local_url($redirect)) { - $redirect = url('index'); + if (!is_local_url($loginRedirect)) { + $loginRedirect = url('index'); } - header("Location: {$redirect}"); + header("Location: {$loginRedirect}"); return; } diff --git a/public/settings.php b/public/settings.php index 83912a13..f19ff62d 100644 --- a/public/settings.php +++ b/public/settings.php @@ -8,8 +8,17 @@ if (!user_session_active()) { $errors = []; -$currentEmail = user_email_get(user_session_current('user_id')); -$isRestricted = user_warning_check_restriction(user_session_current('user_id')); +$currentUserId = user_session_current('user_id'); +$currentEmail = user_email_get($currentUserId); +$isRestricted = user_warning_check_restriction($currentUserId); + +$getTwoFactorInfo = db_prepare(' + SELECT `username`, `user_totp_key` IS NOT NULL AS `totp_enabled` + FROM `msz_users` + WHERE `user_id` = :user_id +'); +$getTwoFactorInfo->bindValue('user_id', $currentUserId); +$twoFactorInfo = db_fetch($getTwoFactorInfo); if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!csrf_verify('settings', $_POST['csrf'] ?? '')) { @@ -23,7 +32,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $sessionId = intval($sessionId); $session = user_session_find($sessionId); - if (!$session || (int)$session['user_id'] !== user_session_current('user_id')) { + if (!$session || (int)$session['user_id'] !== $currentUserId) { $errors[] = "Session #{$sessionId} does not exist."; break; } elseif ((int)$session['session_id'] === user_session_current('session_id')) { @@ -31,14 +40,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } user_session_delete($session['session_id']); - audit_log(MSZ_AUDIT_PERSONAL_SESSION_DESTROY, user_session_current('user_id'), [ + audit_log(MSZ_AUDIT_PERSONAL_SESSION_DESTROY, $currentUserId, [ $session['session_id'], ]); } } elseif ($_POST['session'] === 'all') { $currentSessionKilled = true; - user_session_purge_all(user_session_current('user_id')); - audit_log(MSZ_AUDIT_PERSONAL_SESSION_DESTROY_ALL, user_session_current('user_id')); + user_session_purge_all($currentUserId); + audit_log(MSZ_AUDIT_PERSONAL_SESSION_DESTROY_ALL, $currentUserId); } if ($currentSessionKilled) { @@ -50,15 +59,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!empty($_POST['role']) && !$isRestricted) { $roleId = (int)($_POST['role']['id'] ?? 0); - if ($roleId > 0 && user_role_has(user_session_current('user_id'), $roleId)) { + if ($roleId > 0 && user_role_has($currentUserId, $roleId)) { switch ($_POST['role']['mode'] ?? '') { case 'display': - user_role_set_display(user_session_current('user_id'), $roleId); + user_role_set_display($currentUserId, $roleId); break; case 'leave': if (user_role_can_leave($roleId)) { - user_role_remove(user_session_current('user_id'), $roleId); + user_role_remove($currentUserId, $roleId); } else { $errors[] = "You're not allow to leave this role, an administrator has to remove it for you."; } @@ -69,8 +78,44 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } + if (isset($_POST['tfa']['enable']) && (bool)$twoFactorInfo['totp_enabled'] !== (bool)$_POST['tfa']['enable']) { + $updateTotpKey = db_prepare(' + UPDATE `msz_users` + SET `user_totp_key` = :key + WHERE `user_id` = :user_id + '); + $updateTotpKey->bindValue('user_id', $currentUserId); + + if ((bool)$_POST['tfa']['enable']) { + $tfaKey = totp_generate_key(); + + tpl_vars([ + 'settings_2fa_code' => $tfaKey, + 'settings_2fa_image' => totp_qrcode(totp_uri( + sprintf( + '%s:%s', + config_get_default('Misuzu', 'Site', 'name'), + $twoFactorInfo['username'] + ), + $tfaKey, + $_SERVER['HTTP_HOST'] + )), + ]); + + $updateTotpKey->bindValue('key', $tfaKey); + } else { + $updateTotpKey->bindValue('key', null); + } + + if ($updateTotpKey->execute()) { + $twoFactorInfo['totp_enabled'] = !$twoFactorInfo['totp_enabled']; + } else { + $errors[] = 'Failed to save Two Factor Authentication state.'; + } + } + if (!empty($_POST['current_password'])) { - if (!user_password_verify_db(user_session_current('user_id'), $_POST['current_password'] ?? '')) { + if (!user_password_verify_db($currentUserId, $_POST['current_password'] ?? '')) { $errors[] = 'Your password was incorrect.'; } else { // Changing e-mail @@ -100,8 +145,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $errors[] = 'Unknown e-mail validation error.'; } } else { - user_email_set(user_session_current('user_id'), $_POST['email']['new']); - audit_log(MSZ_AUDIT_PERSONAL_EMAIL_CHANGE, user_session_current('user_id'), [ + user_email_set($currentUserId, $_POST['email']['new']); + audit_log(MSZ_AUDIT_PERSONAL_EMAIL_CHANGE, $currentUserId, [ $_POST['email']['new'], ]); } @@ -118,8 +163,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($checkPassword !== '') { $errors[] = 'The given passwords was too weak.'; } else { - user_password_set(user_session_current('user_id'), $_POST['password']['new']); - audit_log(MSZ_AUDIT_PERSONAL_PASSWORD_CHANGE, user_session_current('user_id')); + user_password_set($currentUserId, $_POST['password']['new']); + audit_log(MSZ_AUDIT_PERSONAL_PASSWORD_CHANGE, $currentUserId); } } } @@ -131,17 +176,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $sessions = [ 'list' => [], 'active' => user_session_current('session_id'), - 'pagination' => pagination_create(user_session_count(user_session_current('user_id')), 15), + 'pagination' => pagination_create(user_session_count($currentUserId), 15), ]; $logins = [ 'list' => [], - 'pagination' => pagination_create(user_login_attempts_count(user_session_current('user_id')), 15), + 'pagination' => pagination_create(user_login_attempts_count($currentUserId), 15), ]; $logs = [ 'list' => [], - 'pagination' => pagination_create(audit_log_count(user_session_current('user_id')), 15), + 'pagination' => pagination_create(audit_log_count($currentUserId), 15), 'strings' => MSZ_AUDIT_LOG_STRINGS, ]; @@ -155,20 +200,20 @@ foreach (['sessions', 'logins', 'logs'] as $section) { $sessions['list'] = user_session_list( $sessions['pagination']['offset'], $sessions['pagination']['range'], - user_session_current('user_id') + $currentUserId ); $logins['list'] = user_login_attempts_list( $logins['pagination']['offset'], $logins['pagination']['range'], - user_session_current('user_id') + $currentUserId ); $logs['list'] = audit_log_list( $logs['pagination']['offset'], $logs['pagination']['range'], - user_session_current('user_id') + $currentUserId ); -$userRoles = user_role_all_user(user_session_current('user_id')); +$userRoles = user_role_all_user($currentUserId); echo tpl_render('user.settings', [ 'errors' => $errors, @@ -177,6 +222,7 @@ echo tpl_render('user.settings', [ 'logins' => $logins, 'logs' => $logs, 'user_roles' => $userRoles, - 'user_display_role' => user_role_get_display(user_session_current('user_id')), + 'user_display_role' => user_role_get_display($currentUserId), 'is_restricted' => $isRestricted, + 'settings_2fa_enabled' => $twoFactorInfo['totp_enabled'], ]); diff --git a/src/Users/user.php b/src/Users/user.php index ab494058..5ce2c908 100644 --- a/src/Users/user.php +++ b/src/Users/user.php @@ -59,7 +59,7 @@ function user_create( function user_find_for_login(string $usernameOrMail): array { $getUser = db_prepare(' - SELECT `user_id`, `password` + SELECT `user_id`, `password`, `user_totp_key` FROM `msz_users` WHERE LOWER(`email`) = LOWER(:email) OR LOWER(`username`) = LOWER(:username) diff --git a/src/base32.php b/src/base32.php new file mode 100644 index 00000000..8195a828 --- /dev/null +++ b/src/base32.php @@ -0,0 +1,39 @@ +> $shift) : ''; + } + + return $out; +} + +function base32_encode(string $data): string +{ + $bin = ''; + $encoded = ''; + $length = strlen($data); + + for ($i = 0; $i < $length; $i++) { + $bin .= sprintf('%08b', ord($data[$i])); + } + + $bin = str_split($bin, 5); + $last = array_pop($bin); + $bin[] = str_pad($last, 5, '0', STR_PAD_RIGHT); + + foreach ($bin as $part) { + $encoded .= MSZ_BASE32_CHARS[bindec($part)]; + } + + return $encoded; +} diff --git a/src/otp.php b/src/otp.php new file mode 100644 index 00000000..230ac76e --- /dev/null +++ b/src/otp.php @@ -0,0 +1,72 @@ + $secret, + ]; + + if (!empty($issuer)) { + $query['issuer'] = $issuer; + } + + return sprintf('otpauth://totp/%s?%s', $name, http_build_query($query)); +} + +function totp_qrcode(string $uri): string +{ + $options = new QROptions([ + 'version' => 5, + 'outputType' => QRCode::OUTPUT_IMAGE_PNG, + 'eccLevel' => QRCode::ECC_L, + ]); + $qrcode = new QRCode($options); + + return $qrcode->render($uri); +} + +// will generate a 26 character code +function totp_generate_key(): string +{ + return base32_encode(random_bytes(16)); +} diff --git a/templates/auth/twofactor.twig b/templates/auth/twofactor.twig new file mode 100644 index 00000000..14da0d26 --- /dev/null +++ b/templates/auth/twofactor.twig @@ -0,0 +1,40 @@ +{% extends 'auth/master.twig' %} +{% from 'macros.twig' import container_title %} +{% from '_layout/input.twig' import input_hidden, input_csrf, input_text %} + +{% set title = 'Two Factor Authentication' %} + +{% block content %} +
+ {{ container_title(' Two Factor Authentication') }} + + {{ input_csrf('login') }} + {{ input_hidden('login[redirect]', login_redirect) }} + {{ input_hidden('login[username]', login_username) }} + {{ input_hidden('login[password]', login_password) }} + + {% if login_notices|length > 0 %} +
+
+ {% for notice in login_notices %} +

{{ notice }}

+ {% endfor %} +
+
+ {% endif %} + + + +
+ + Log out +
+ +{% endblock %} diff --git a/templates/user/settings.twig b/templates/user/settings.twig index 5eab0d59..96be8b25 100644 --- a/templates/user/settings.twig +++ b/templates/user/settings.twig @@ -6,7 +6,8 @@ {% set title = 'Settings' %} {% set menu = { 'account': [' Account', true], - 'roles': [' Roles', not is_restricted], + 'roles': [' Roles', not is_restricted], + 'tfa': [' Two Factor Authentication', true], 'sessions': [' Sessions', true], 'login-attempts': [' Login Attempts', true], 'account-log': [' Account Log', true], @@ -40,7 +41,7 @@
- + {{ container_title(' Account') }} {{ input_csrf('settings') }} @@ -143,6 +144,40 @@
{% endif %} + + {{ container_title(' Two Factor Authentication') }} + {{ input_csrf('settings') }} + +
+

Secure your account by requiring a second step during log in in the form of a time based code. You can use applications like Authy, Google or Microsoft Authenticator or other compliant TOTP applications.

+
+ +
+ {% if settings_2fa_image is defined and settings_2fa_code is defined %} +
+
+ {{ settings_2fa_code }} +
+ {{ settings_2fa_code }} +
+ {% endif %} + +
+ {% if settings_2fa_enabled %} +
+ Two Factor Authentication is enabled! +
+ + {% else %} +
+ Two Factor Authentication is disabled. +
+ + {% endif %} +
+
+
+
{{ container_title(' Sessions') }} {% set spagination = pagination(sessions.pagination, url('settings-index'), null, { @@ -155,7 +190,7 @@
-
+ {{ input_csrf('settings') }} {{ input_hidden('session', 'all') }}