Added 2FA TOTP on admin login

https://github.com/the-djmaze/snappymail/issues/84#issuecomment-818808101
This commit is contained in:
djmaze 2021-07-22 21:36:44 +02:00
parent b76a6ad7a7
commit bf75cf7946
8 changed files with 125 additions and 16 deletions

View file

@ -6,10 +6,11 @@ class RemoteAdminFetch extends AbstractFetchRemote {
* @param {string} sLogin
* @param {string} sPassword
*/
adminLogin(fCallback, sLogin, sPassword) {
adminLogin(fCallback, sLogin, sPassword, sCode) {
this.defaultRequest(fCallback, 'AdminLogin', {
Login: sLogin,
Password: sPassword
Password: sPassword,
TOTP: sCode
});
}

View file

@ -79,14 +79,13 @@
}
.controls {
.inputLoginForm, .inputLogin, .inputPassword {
.input-block-level {
font-size: 18px;
height: 40px;
line-height: 20px;
padding-left: 12px;
padding-right: 12px;
}
.inputLogin, .inputPassword {
.inputIcon {
padding-right: 35px;
}

View file

@ -17,6 +17,7 @@ class LoginAdminView extends AbstractViewCenter {
this.addObservables({
login: '',
password: '',
totp: '',
loginError: false,
passwordError: false,
@ -59,7 +60,8 @@ class LoginAdminView extends AbstractViewCenter {
}
},
name,
pass
pass,
this.totp()
);
}

View file

@ -179,10 +179,13 @@ trait Admin
$this->Logger()->AddSecret($sPassword);
$totp = $this->Config()->Get('security', 'admin_totp', '');
if (0 === strlen($sLogin) || 0 === strlen($sPassword) ||
!$this->Config()->Get('security', 'allow_admin_panel', true) ||
$sLogin !== $this->Config()->Get('security', 'admin_login', '') ||
!$this->Config()->ValidatePassword($sPassword))
!$this->Config()->ValidatePassword($sPassword)
|| ($totp && !\SnappyMail\TOTP::Verify($totp, $this->GetActionParam('TOTP', ''))))
{
$this->loginErrorDelay();
$this->LoggerAuthHelper(null, $this->getAdditionalLogParamsByUserLogin($sLogin, true));

View file

@ -168,6 +168,7 @@ class Application extends \RainLoop\Config\AbstractConfig
'admin_login' => array('admin', 'Login and password for web admin panel'),
'admin_password' => array(''),
'admin_totp' => array(''),
'allow_admin_panel' => array(true, 'Access settings'),
'hide_x_mailer_header' => array(true),
'admin_panel_host' => array(''),

View file

@ -0,0 +1,98 @@
<?php
namespace SnappyMail;
abstract class TOTP
{
public function Verify(string $sSecret, string $sCode) : bool
{
$key = static::Base32Decode($sSecret);
$algo = 'SHA1'; // Google Authenticator doesn't support SHA256
$digits = 6; // Google Authenticator doesn't support 8
$modulo = \pow(10, $digits);
$timeSlice = \floor(\time() / 30);
$discrepancy = 1;
for ($i = -$discrepancy; $i <= $discrepancy; ++$i) {
// Pack time into binary string
$counter = \str_pad(\pack('N*', $timeSlice + $i), 8, "\x00", STR_PAD_LEFT);
// Hash it with users secret key
$hm = \hash_hmac($algo, $counter, $key, true);
// Unpak 4 bytes of the result, use last nipple of result as index/offset
$value = \unpack('N', \substr($hm, (\ord(\substr($hm, -1)) & 0x0F), 4));
// Only 32 bits
$value = $value[1] & 0x7FFFFFFF;
$value = \str_pad($value % $modulo, $digits, '0', STR_PAD_LEFT);
if (\hash_equals($value, $sCode)) {
return true;
}
}
return false;
}
public function CreateSecret() : string
{
$CHARS = \array_keys(static::$map);
$length = 16;
$secret = '';
while (0 < $length--) {
$secret .= $CHARS[\random_int(0,31)];
}
return $secret;
}
protected static $map = array(
'A' => 0, // ord 65
'B' => 1,
'C' => 2,
'D' => 3,
'E' => 4,
'F' => 5,
'G' => 6,
'H' => 7,
'I' => 8,
'J' => 9,
'K' => 10,
'L' => 11,
'M' => 12,
'N' => 13,
'O' => 14,
'P' => 15,
'Q' => 16,
'R' => 17,
'S' => 18,
'T' => 19,
'U' => 20,
'V' => 21,
'W' => 22,
'X' => 23,
'Y' => 24,
'Z' => 25, // ord 90
'2' => 26, // ord 50
'3' => 27,
'4' => 28,
'5' => 29,
'6' => 30,
'7' => 31 // ord 55
);
protected static function Base32Decode(string $data)
{
$data = \strtoupper(\rtrim($data, "=\x20\t\n\r\0\x0B"));
$dataSize = \strlen($data);
$buf = 0;
$bufSize = 0;
$res = '';
for ($i = 0; $i < $dataSize; ++$i) {
$c = $data[$i];
if (isset(static::$map[$c])) {
$buf = ($buf << 5) | static::$map[$c];
$bufSize += 5;
if ($bufSize > 7) {
$bufSize -= 8;
$res .= \chr(($buf & (0xff << $bufSize)) >> $bufSize);
}
}
}
return $res;
}
}

View file

@ -6,7 +6,7 @@
<form action="#/" data-bind="submit: submitForm, css: {'errorAnimated': formError, 'submitting': submitRequest()}">
<div class="controls" data-bind="css: {'error': loginError}">
<div class="input-append">
<input required="" type="text" class="input-block-level inputLogin"
<input required="" type="text" class="input-block-level inputIcon"
autofocus="" autocomplete="username" autocorrect="off" autocapitalize="off" spellcheck="false"
data-bind="textInput: login, disable: submitRequest"
data-i18n="[placeholder]LOGIN/LABEL_LOGIN" />
@ -15,15 +15,20 @@
</div>
<div class="controls" data-bind="css: {'error': passwordError}">
<div class="input-append">
<input required="" type="password" class="input-block-level inputPassword"
<input required="" type="password" class="input-block-level inputIcon"
autocomplete="current-password" autocorrect="off" autocapitalize="off" spellcheck="false"
data-bind="textInput: password, disable: submitRequest"
data-i18n="[placeholder]LOGIN/LABEL_PASSWORD" />
<span class="add-on" tabindex="-1"
data-bind="command: submitCommand" data-i18n="[title]LOGIN/BUTTON_LOGIN">
<i class="fontastic" data-bind="visible: '' === password()">🔑</i>
<button type="submit" class="btn-submit-icon-wrp login-submit-icon fontastic" data-bind="visible: '' !== password()"></button>
</span>
<span class="add-on fontastic">🔑</span>
</div>
</div>
<div class="controls">
<div class="input-append">
<input type="text" pattern="[0-9]*" inputmode="numeric" class="input-block-level inputIcon"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-bind="textInput: totp, disable: submitRequest"
data-i18n="[placeholder]LOGIN/LABEL_TOTP" />
<button type="submit" class="add-on btn-submit-icon-wrp login-submit-icon fontastic" data-bind="command: submitCommand" data-i18n="[title]LOGIN/BUTTON_LOGIN"></button>
</div>
</div>
<div id="plugin-Login-BottomControlGroup"></div>

View file

@ -9,7 +9,7 @@
data-bind="submit: submitForm, css: {'errorAnimated': formError, 'submitting': submitRequest()}">
<div class="controls" data-bind="css: {'error': emailError}">
<div class="input-append">
<input required="" type="text" class="input-block-level inputLogin" pattern="[^@\s]+(@[^\s]+)?" inputmode="email"
<input required="" type="text" class="input-block-level inputIcon" pattern="[^@\s]+(@[^\s]+)?" inputmode="email"
autofocus="" autocomplete="email" autocorrect="off" autocapitalize="off" spellcheck="false"
data-bind="textInput: email, disable: submitRequest"
data-i18n="[placeholder]GLOBAL/EMAIL" />
@ -18,7 +18,7 @@
</div>
<div class="controls" data-bind="css: {'error': passwordError}">
<div class="input-append">
<input required="" type="password" class="input-block-level inputPassword"
<input required="" type="password" class="input-block-level inputIcon"
autocomplete="current-password" autocorrect="off" autocapitalize="off" spellcheck="false"
data-bind="textInput: password, disable: submitRequest"
data-i18n="[placeholder]GLOBAL/PASSWORD" />