Added time based two factor authentication support.
This commit is contained in:
parent
8d5b49b0e8
commit
305599c8cb
13 changed files with 468 additions and 40 deletions
43
assets/less/classes/settings/two-factor.less
Normal file
43
assets/less/classes/settings/two-factor.less
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -184,6 +184,7 @@ html {
|
||||||
@import "classes/settings/session";
|
@import "classes/settings/session";
|
||||||
@import "classes/settings/sessions";
|
@import "classes/settings/sessions";
|
||||||
@import "classes/settings/role";
|
@import "classes/settings/role";
|
||||||
|
@import "classes/settings/two-factor";
|
||||||
|
|
||||||
// News
|
// News
|
||||||
@import "classes/news/container";
|
@import "classes/news/container";
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
"issues": "https://github.com/flashwave/misuzu/issues"
|
"issues": "https://github.com/flashwave/misuzu/issues"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=7.2",
|
"php": "^7.2",
|
||||||
"ext-bcmath": "*",
|
"ext-bcmath": "*",
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"twig/twig": "~2.4",
|
"twig/twig": "~2.4",
|
||||||
|
@ -16,7 +16,8 @@
|
||||||
"geoip2/geoip2": "~2.0",
|
"geoip2/geoip2": "~2.0",
|
||||||
"twig/extensions": "^1.5",
|
"twig/extensions": "^1.5",
|
||||||
"filp/whoops": "^2.2",
|
"filp/whoops": "^2.2",
|
||||||
"jublonet/codebird-php": "^3.1"
|
"jublonet/codebird-php": "^3.1",
|
||||||
|
"chillerlan/php-qrcode": "^3.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"classmap": [
|
"classmap": [
|
||||||
|
|
104
composer.lock
generated
104
composer.lock
generated
|
@ -4,8 +4,110 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "3d0e94350450c6e4148bf7a0ea7ce697",
|
"content-hash": "6e8574e32141af1cd3300c2fee5a1e4b",
|
||||||
"packages": [
|
"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",
|
"name": "composer/ca-bundle",
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
|
|
20
database/2019_03_09_222347_add_two_factor_auth.php
Normal file
20
database/2019_03_09_222347_add_two_factor_auth.php
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
namespace Misuzu\DatabaseMigrations\AddTwoFactorAuth;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
function migrate_up(PDO $conn): void
|
||||||
|
{
|
||||||
|
$conn->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`;
|
||||||
|
");
|
||||||
|
}
|
|
@ -28,6 +28,7 @@ $errorHandler->register();
|
||||||
|
|
||||||
require_once 'src/array.php';
|
require_once 'src/array.php';
|
||||||
require_once 'src/audit_log.php';
|
require_once 'src/audit_log.php';
|
||||||
|
require_once 'src/base32.php';
|
||||||
require_once 'src/changelog.php';
|
require_once 'src/changelog.php';
|
||||||
require_once 'src/colour.php';
|
require_once 'src/colour.php';
|
||||||
require_once 'src/comments.php';
|
require_once 'src/comments.php';
|
||||||
|
@ -40,6 +41,7 @@ require_once 'src/integer.php';
|
||||||
require_once 'src/mail.php';
|
require_once 'src/mail.php';
|
||||||
require_once 'src/manage.php';
|
require_once 'src/manage.php';
|
||||||
require_once 'src/news.php';
|
require_once 'src/news.php';
|
||||||
|
require_once 'src/otp.php';
|
||||||
require_once 'src/pagination.php';
|
require_once 'src/pagination.php';
|
||||||
require_once 'src/perms.php';
|
require_once 'src/perms.php';
|
||||||
require_once 'src/string.php';
|
require_once 'src/string.php';
|
||||||
|
|
|
@ -28,6 +28,7 @@ while (!empty($login->value('array'))) {
|
||||||
}
|
}
|
||||||
|
|
||||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||||
|
$loginRedirect = $login->redirect->value('string', '');
|
||||||
|
|
||||||
if ($login->username->empty() || $login->password->empty()) {
|
if ($login->username->empty() || $login->password->empty()) {
|
||||||
$notices[] = "You didn't fill in a username and/or password.";
|
$notices[] = "You didn't fill in a username and/or password.";
|
||||||
|
@ -39,12 +40,14 @@ while (!empty($login->value('array'))) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$userData = user_find_for_login($login->username->value('string', ''));
|
$loginUsername = $login->username->value('string', '');
|
||||||
$loginFailedError = sprintf(
|
$userData = user_find_for_login($loginUsername);
|
||||||
"Invalid username or password, %d attempt%s remaining.",
|
$attemptsRemainingError = sprintf(
|
||||||
|
"%d attempt%s remaining",
|
||||||
$remainingAttempts - 1,
|
$remainingAttempts - 1,
|
||||||
$remainingAttempts === 2 ? '' : 's'
|
$remainingAttempts === 2 ? '' : 's'
|
||||||
);
|
);
|
||||||
|
$loginFailedError = "Invalid username or password, {$attemptsRemainingError}.";
|
||||||
|
|
||||||
if (empty($userData) || $userData['user_id'] < 1) {
|
if (empty($userData) || $userData['user_id'] < 1) {
|
||||||
user_login_attempt_record(false, null, $ipAddress, $userAgent);
|
user_login_attempt_record(false, null, $ipAddress, $userAgent);
|
||||||
|
@ -52,19 +55,45 @@ while (!empty($login->value('array'))) {
|
||||||
break;
|
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);
|
user_login_attempt_record(false, $userData['user_id'], $ipAddress, $userAgent);
|
||||||
$notices[] = $loginFailedError;
|
$notices[] = $loginFailedError;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
user_login_attempt_record(true, $userData['user_id'], $ipAddress, $userAgent);
|
|
||||||
|
|
||||||
if ($loginPermission > 0 && !perms_check_user(MSZ_PERMS_GENERAL, $userData['user_id'], $loginPermission)) {
|
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.";
|
$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;
|
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);
|
$sessionKey = user_session_create($userData['user_id'], $ipAddress, $userAgent);
|
||||||
|
|
||||||
if (empty($sessionKey)) {
|
if (empty($sessionKey)) {
|
||||||
|
@ -77,13 +106,11 @@ while (!empty($login->value('array'))) {
|
||||||
$cookieValue = base64url_encode(user_session_cookie_pack($userData['user_id'], $sessionKey));
|
$cookieValue = base64url_encode(user_session_cookie_pack($userData['user_id'], $sessionKey));
|
||||||
setcookie('msz_auth', $cookieValue, $cookieLife, '/', '', true, true);
|
setcookie('msz_auth', $cookieValue, $cookieLife, '/', '', true, true);
|
||||||
|
|
||||||
$redirect = $login->redirect->value('string', '');
|
if (!is_local_url($loginRedirect)) {
|
||||||
|
$loginRedirect = url('index');
|
||||||
if (!is_local_url($redirect)) {
|
|
||||||
$redirect = url('index');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header("Location: {$redirect}");
|
header("Location: {$loginRedirect}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,17 @@ if (!user_session_active()) {
|
||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
$currentEmail = user_email_get(user_session_current('user_id'));
|
$currentUserId = user_session_current('user_id');
|
||||||
$isRestricted = user_warning_check_restriction(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 ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
if (!csrf_verify('settings', $_POST['csrf'] ?? '')) {
|
if (!csrf_verify('settings', $_POST['csrf'] ?? '')) {
|
||||||
|
@ -23,7 +32,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$sessionId = intval($sessionId);
|
$sessionId = intval($sessionId);
|
||||||
$session = user_session_find($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.";
|
$errors[] = "Session #{$sessionId} does not exist.";
|
||||||
break;
|
break;
|
||||||
} elseif ((int)$session['session_id'] === user_session_current('session_id')) {
|
} 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']);
|
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'],
|
$session['session_id'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} elseif ($_POST['session'] === 'all') {
|
} elseif ($_POST['session'] === 'all') {
|
||||||
$currentSessionKilled = true;
|
$currentSessionKilled = true;
|
||||||
user_session_purge_all(user_session_current('user_id'));
|
user_session_purge_all($currentUserId);
|
||||||
audit_log(MSZ_AUDIT_PERSONAL_SESSION_DESTROY_ALL, user_session_current('user_id'));
|
audit_log(MSZ_AUDIT_PERSONAL_SESSION_DESTROY_ALL, $currentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($currentSessionKilled) {
|
if ($currentSessionKilled) {
|
||||||
|
@ -50,15 +59,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
if (!empty($_POST['role']) && !$isRestricted) {
|
if (!empty($_POST['role']) && !$isRestricted) {
|
||||||
$roleId = (int)($_POST['role']['id'] ?? 0);
|
$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'] ?? '') {
|
switch ($_POST['role']['mode'] ?? '') {
|
||||||
case 'display':
|
case 'display':
|
||||||
user_role_set_display(user_session_current('user_id'), $roleId);
|
user_role_set_display($currentUserId, $roleId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'leave':
|
case 'leave':
|
||||||
if (user_role_can_leave($roleId)) {
|
if (user_role_can_leave($roleId)) {
|
||||||
user_role_remove(user_session_current('user_id'), $roleId);
|
user_role_remove($currentUserId, $roleId);
|
||||||
} else {
|
} else {
|
||||||
$errors[] = "You're not allow to leave this role, an administrator has to remove it for you.";
|
$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 (!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.';
|
$errors[] = 'Your password was incorrect.';
|
||||||
} else {
|
} else {
|
||||||
// Changing e-mail
|
// Changing e-mail
|
||||||
|
@ -100,8 +145,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$errors[] = 'Unknown e-mail validation error.';
|
$errors[] = 'Unknown e-mail validation error.';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
user_email_set(user_session_current('user_id'), $_POST['email']['new']);
|
user_email_set($currentUserId, $_POST['email']['new']);
|
||||||
audit_log(MSZ_AUDIT_PERSONAL_EMAIL_CHANGE, user_session_current('user_id'), [
|
audit_log(MSZ_AUDIT_PERSONAL_EMAIL_CHANGE, $currentUserId, [
|
||||||
$_POST['email']['new'],
|
$_POST['email']['new'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
@ -118,8 +163,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
if ($checkPassword !== '') {
|
if ($checkPassword !== '') {
|
||||||
$errors[] = 'The given passwords was too weak.';
|
$errors[] = 'The given passwords was too weak.';
|
||||||
} else {
|
} else {
|
||||||
user_password_set(user_session_current('user_id'), $_POST['password']['new']);
|
user_password_set($currentUserId, $_POST['password']['new']);
|
||||||
audit_log(MSZ_AUDIT_PERSONAL_PASSWORD_CHANGE, user_session_current('user_id'));
|
audit_log(MSZ_AUDIT_PERSONAL_PASSWORD_CHANGE, $currentUserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -131,17 +176,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$sessions = [
|
$sessions = [
|
||||||
'list' => [],
|
'list' => [],
|
||||||
'active' => user_session_current('session_id'),
|
'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 = [
|
$logins = [
|
||||||
'list' => [],
|
'list' => [],
|
||||||
'pagination' => pagination_create(user_login_attempts_count(user_session_current('user_id')), 15),
|
'pagination' => pagination_create(user_login_attempts_count($currentUserId), 15),
|
||||||
];
|
];
|
||||||
|
|
||||||
$logs = [
|
$logs = [
|
||||||
'list' => [],
|
'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,
|
'strings' => MSZ_AUDIT_LOG_STRINGS,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -155,20 +200,20 @@ foreach (['sessions', 'logins', 'logs'] as $section) {
|
||||||
$sessions['list'] = user_session_list(
|
$sessions['list'] = user_session_list(
|
||||||
$sessions['pagination']['offset'],
|
$sessions['pagination']['offset'],
|
||||||
$sessions['pagination']['range'],
|
$sessions['pagination']['range'],
|
||||||
user_session_current('user_id')
|
$currentUserId
|
||||||
);
|
);
|
||||||
$logins['list'] = user_login_attempts_list(
|
$logins['list'] = user_login_attempts_list(
|
||||||
$logins['pagination']['offset'],
|
$logins['pagination']['offset'],
|
||||||
$logins['pagination']['range'],
|
$logins['pagination']['range'],
|
||||||
user_session_current('user_id')
|
$currentUserId
|
||||||
);
|
);
|
||||||
$logs['list'] = audit_log_list(
|
$logs['list'] = audit_log_list(
|
||||||
$logs['pagination']['offset'],
|
$logs['pagination']['offset'],
|
||||||
$logs['pagination']['range'],
|
$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', [
|
echo tpl_render('user.settings', [
|
||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
|
@ -177,6 +222,7 @@ echo tpl_render('user.settings', [
|
||||||
'logins' => $logins,
|
'logins' => $logins,
|
||||||
'logs' => $logs,
|
'logs' => $logs,
|
||||||
'user_roles' => $userRoles,
|
'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,
|
'is_restricted' => $isRestricted,
|
||||||
|
'settings_2fa_enabled' => $twoFactorInfo['totp_enabled'],
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -59,7 +59,7 @@ function user_create(
|
||||||
function user_find_for_login(string $usernameOrMail): array
|
function user_find_for_login(string $usernameOrMail): array
|
||||||
{
|
{
|
||||||
$getUser = db_prepare('
|
$getUser = db_prepare('
|
||||||
SELECT `user_id`, `password`
|
SELECT `user_id`, `password`, `user_totp_key`
|
||||||
FROM `msz_users`
|
FROM `msz_users`
|
||||||
WHERE LOWER(`email`) = LOWER(:email)
|
WHERE LOWER(`email`) = LOWER(:email)
|
||||||
OR LOWER(`username`) = LOWER(:username)
|
OR LOWER(`username`) = LOWER(:username)
|
||||||
|
|
39
src/base32.php
Normal file
39
src/base32.php
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
define('MSZ_BASE32_CHARS', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567');
|
||||||
|
|
||||||
|
function base32_decode(string $string): string
|
||||||
|
{
|
||||||
|
$out = '';
|
||||||
|
$length = strlen($string);
|
||||||
|
$char = $shift = 0;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$char <<= 5;
|
||||||
|
$char += stripos(MSZ_BASE32_CHARS, $string[$i]);
|
||||||
|
$shift = ($shift + 5) % 8;
|
||||||
|
$out .= $shift < 5 ? chr(($char & (0xFF << $shift)) >> $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;
|
||||||
|
}
|
72
src/otp.php
Normal file
72
src/otp.php
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
use chillerlan\QRCode\QRCode;
|
||||||
|
use chillerlan\QRCode\QROptions;
|
||||||
|
|
||||||
|
define('MSZ_TOTP_DEFAULT_DIGITS', 6);
|
||||||
|
define('MSZ_TOTP_DEFAULT_ALGO', 'sha1');
|
||||||
|
define('MSZ_TOTP_DEFAULT_TOTP_INTERVAL', 30);
|
||||||
|
|
||||||
|
function otp_generate(
|
||||||
|
int $data,
|
||||||
|
string $secret,
|
||||||
|
int $digits = MSZ_TOTP_DEFAULT_DIGITS,
|
||||||
|
string $algo = MSZ_TOTP_DEFAULT_ALGO
|
||||||
|
): ?string {
|
||||||
|
$hash = hash_hmac($algo, pack('J', $data), base32_decode($secret), true);
|
||||||
|
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
|
||||||
|
|
||||||
|
$bin = 0;
|
||||||
|
$bin |= (ord($hash[$offset]) & 0x7F) << 24;
|
||||||
|
$bin |= (ord($hash[$offset + 1]) & 0xFF) << 16;
|
||||||
|
$bin |= (ord($hash[$offset + 2]) & 0xFF) << 8;
|
||||||
|
$bin |= (ord($hash[$offset + 3]) & 0xFF);
|
||||||
|
$otp = $bin % pow(10, $digits);
|
||||||
|
|
||||||
|
return str_pad($otp, $digits, STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function totp_timecode(int $timestamp, int $interval = MSZ_OTP_DEFAULT_TOTP_INTERVAL): int
|
||||||
|
{
|
||||||
|
return ($timestamp * 1000) / ($interval * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function totp_generate(
|
||||||
|
string $secret,
|
||||||
|
?int $time = null,
|
||||||
|
int $interval = MSZ_TOTP_DEFAULT_TOTP_INTERVAL,
|
||||||
|
int $digits = MSZ_TOTP_DEFAULT_DIGITS,
|
||||||
|
string $algo = MSZ_TOTP_DEFAULT_ALGO
|
||||||
|
): string {
|
||||||
|
return otp_generate(totp_timecode($time ?? time(), $interval), $secret, $digits, $algo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function totp_uri(string $name, string $secret, string $issuer = ''): string
|
||||||
|
{
|
||||||
|
$query = [
|
||||||
|
'secret' => $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));
|
||||||
|
}
|
40
templates/auth/twofactor.twig
Normal file
40
templates/auth/twofactor.twig
Normal file
|
@ -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 %}
|
||||||
|
<form class="container auth__container auth__twofactor" method="post" action="{{ url('auth-login') }}">
|
||||||
|
{{ container_title('<i class="fas fa-user-shield fa-fw"></i> 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 %}
|
||||||
|
<div class="warning auth__warning">
|
||||||
|
<div class="warning__content">
|
||||||
|
{% for notice in login_notices %}
|
||||||
|
<p class="auth__warning__paragraph">{{ notice }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<label class="auth__label">
|
||||||
|
<div class="auth__label__text">
|
||||||
|
Code
|
||||||
|
</div>
|
||||||
|
<div class="auth__label__value">
|
||||||
|
{{ input_text('login[tfa]', 'input__text--monospace auth__label__input', '', 'text', '', true, {'maxlength':6}, 1) }}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="auth__buttons">
|
||||||
|
<button class="input__button auth__buttons__button" tabindex="2">Log in</button>
|
||||||
|
<a href="{{ url('auth-login') }}" class="input__button auth__buttons__button auth__buttons__button--minor" tabindex="3">Log out</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -6,7 +6,8 @@
|
||||||
{% set title = 'Settings' %}
|
{% set title = 'Settings' %}
|
||||||
{% set menu = {
|
{% set menu = {
|
||||||
'account': ['<i class="fas fa-user fa-fw"></i> Account', true],
|
'account': ['<i class="fas fa-user fa-fw"></i> Account', true],
|
||||||
'roles': ['<i class="fas fa-user-check"></i> Roles', not is_restricted],
|
'roles': ['<i class="fas fa-user-check fa-fw"></i> Roles', not is_restricted],
|
||||||
|
'tfa': ['<i class="fas fa-unlock-alt fa-fw"></i> Two Factor Authentication', true],
|
||||||
'sessions': ['<i class="fas fa-key fa-fw"></i> Sessions', true],
|
'sessions': ['<i class="fas fa-key fa-fw"></i> Sessions', true],
|
||||||
'login-attempts': ['<i class="fas fa-user-lock fa-fw"></i> Login Attempts', true],
|
'login-attempts': ['<i class="fas fa-user-lock fa-fw"></i> Login Attempts', true],
|
||||||
'account-log': ['<i class="fas fa-file-alt fa-fw"></i> Account Log', true],
|
'account-log': ['<i class="fas fa-file-alt fa-fw"></i> Account Log', true],
|
||||||
|
@ -40,7 +41,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings__wrapper__content">
|
<div class="settings__wrapper__content">
|
||||||
<form action="" method="post" class="container settings__container" id="account">
|
<form action="{{ url('settings-index') }}" method="post" class="container settings__container" id="account">
|
||||||
{{ container_title('<i class="fas fa-user fa-fw"></i> Account') }}
|
{{ container_title('<i class="fas fa-user fa-fw"></i> Account') }}
|
||||||
{{ input_csrf('settings') }}
|
{{ input_csrf('settings') }}
|
||||||
|
|
||||||
|
@ -143,6 +144,40 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<form action="{{ url('settings-index') }}" method="post" class="container settings__container" id="tfa">
|
||||||
|
{{ container_title('<i class="fas fa-unlock-alt fa-fw"></i> Two Factor Authentication') }}
|
||||||
|
{{ input_csrf('settings') }}
|
||||||
|
|
||||||
|
<div class="settings__description">
|
||||||
|
<p>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.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings__two-factor">
|
||||||
|
{% if settings_2fa_image is defined and settings_2fa_code is defined %}
|
||||||
|
<div class="settings__two-factor__code">
|
||||||
|
<div class="settings__two-factor__code__text">
|
||||||
|
{{ settings_2fa_code }}
|
||||||
|
</div>
|
||||||
|
<img src="{{ settings_2fa_image }}" alt="{{ settings_2fa_code }}" class="settings__two-factor__code__image">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="settings__two-factor__settings">
|
||||||
|
{% if settings_2fa_enabled %}
|
||||||
|
<div class="settings__two-factor__settings__status">
|
||||||
|
<i class="fas fa-lock fa-fw"></i> Two Factor Authentication is enabled!
|
||||||
|
</div>
|
||||||
|
<button class="input__button" name="tfa[enable]" value="0">Disable</button>
|
||||||
|
{% else %}
|
||||||
|
<div class="settings__two-factor__settings__status">
|
||||||
|
<i class="fas fa-lock-open fa-fw"></i> Two Factor Authentication is disabled.
|
||||||
|
</div>
|
||||||
|
<button class="input__button" name="tfa[enable]" value="1">Enable</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="container settings__container" id="sessions">
|
<div class="container settings__container" id="sessions">
|
||||||
{{ container_title('<i class="fas fa-key fa-fw"></i> Sessions') }}
|
{{ container_title('<i class="fas fa-key fa-fw"></i> Sessions') }}
|
||||||
{% set spagination = pagination(sessions.pagination, url('settings-index'), null, {
|
{% set spagination = pagination(sessions.pagination, url('settings-index'), null, {
|
||||||
|
@ -155,7 +190,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings__sessions">
|
<div class="settings__sessions">
|
||||||
<form action="" method="post" class="settings__sessions__all">
|
<form action="{{ url('settings-index') }}" method="post" class="settings__sessions__all">
|
||||||
{{ input_csrf('settings') }}
|
{{ input_csrf('settings') }}
|
||||||
{{ input_hidden('session', 'all') }}
|
{{ input_hidden('session', 'all') }}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue