mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-07-13 03:27:39 +03:00
Added 2FA TOTP on admin login
https://github.com/the-djmaze/snappymail/issues/84#issuecomment-818808101
This commit is contained in:
parent
b76a6ad7a7
commit
bf75cf7946
8 changed files with 125 additions and 16 deletions
|
|
@ -6,10 +6,11 @@ class RemoteAdminFetch extends AbstractFetchRemote {
|
||||||
* @param {string} sLogin
|
* @param {string} sLogin
|
||||||
* @param {string} sPassword
|
* @param {string} sPassword
|
||||||
*/
|
*/
|
||||||
adminLogin(fCallback, sLogin, sPassword) {
|
adminLogin(fCallback, sLogin, sPassword, sCode) {
|
||||||
this.defaultRequest(fCallback, 'AdminLogin', {
|
this.defaultRequest(fCallback, 'AdminLogin', {
|
||||||
Login: sLogin,
|
Login: sLogin,
|
||||||
Password: sPassword
|
Password: sPassword,
|
||||||
|
TOTP: sCode
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,14 +79,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
.inputLoginForm, .inputLogin, .inputPassword {
|
.input-block-level {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
padding-right: 12px;
|
|
||||||
}
|
}
|
||||||
.inputLogin, .inputPassword {
|
.inputIcon {
|
||||||
padding-right: 35px;
|
padding-right: 35px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class LoginAdminView extends AbstractViewCenter {
|
||||||
this.addObservables({
|
this.addObservables({
|
||||||
login: '',
|
login: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
totp: '',
|
||||||
|
|
||||||
loginError: false,
|
loginError: false,
|
||||||
passwordError: false,
|
passwordError: false,
|
||||||
|
|
@ -59,7 +60,8 @@ class LoginAdminView extends AbstractViewCenter {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
name,
|
name,
|
||||||
pass
|
pass,
|
||||||
|
this.totp()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -179,10 +179,13 @@ trait Admin
|
||||||
|
|
||||||
$this->Logger()->AddSecret($sPassword);
|
$this->Logger()->AddSecret($sPassword);
|
||||||
|
|
||||||
|
$totp = $this->Config()->Get('security', 'admin_totp', '');
|
||||||
|
|
||||||
if (0 === strlen($sLogin) || 0 === strlen($sPassword) ||
|
if (0 === strlen($sLogin) || 0 === strlen($sPassword) ||
|
||||||
!$this->Config()->Get('security', 'allow_admin_panel', true) ||
|
!$this->Config()->Get('security', 'allow_admin_panel', true) ||
|
||||||
$sLogin !== $this->Config()->Get('security', 'admin_login', '') ||
|
$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->loginErrorDelay();
|
||||||
$this->LoggerAuthHelper(null, $this->getAdditionalLogParamsByUserLogin($sLogin, true));
|
$this->LoggerAuthHelper(null, $this->getAdditionalLogParamsByUserLogin($sLogin, true));
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ class Application extends \RainLoop\Config\AbstractConfig
|
||||||
|
|
||||||
'admin_login' => array('admin', 'Login and password for web admin panel'),
|
'admin_login' => array('admin', 'Login and password for web admin panel'),
|
||||||
'admin_password' => array(''),
|
'admin_password' => array(''),
|
||||||
|
'admin_totp' => array(''),
|
||||||
'allow_admin_panel' => array(true, 'Access settings'),
|
'allow_admin_panel' => array(true, 'Access settings'),
|
||||||
'hide_x_mailer_header' => array(true),
|
'hide_x_mailer_header' => array(true),
|
||||||
'admin_panel_host' => array(''),
|
'admin_panel_host' => array(''),
|
||||||
|
|
|
||||||
98
snappymail/v/0.0.0/app/libraries/snappymail/totp.php
Normal file
98
snappymail/v/0.0.0/app/libraries/snappymail/totp.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<form action="#/" data-bind="submit: submitForm, css: {'errorAnimated': formError, 'submitting': submitRequest()}">
|
<form action="#/" data-bind="submit: submitForm, css: {'errorAnimated': formError, 'submitting': submitRequest()}">
|
||||||
<div class="controls" data-bind="css: {'error': loginError}">
|
<div class="controls" data-bind="css: {'error': loginError}">
|
||||||
<div class="input-append">
|
<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"
|
autofocus="" autocomplete="username" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||||
data-bind="textInput: login, disable: submitRequest"
|
data-bind="textInput: login, disable: submitRequest"
|
||||||
data-i18n="[placeholder]LOGIN/LABEL_LOGIN" />
|
data-i18n="[placeholder]LOGIN/LABEL_LOGIN" />
|
||||||
|
|
@ -15,15 +15,20 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="controls" data-bind="css: {'error': passwordError}">
|
<div class="controls" data-bind="css: {'error': passwordError}">
|
||||||
<div class="input-append">
|
<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"
|
autocomplete="current-password" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||||
data-bind="textInput: password, disable: submitRequest"
|
data-bind="textInput: password, disable: submitRequest"
|
||||||
data-i18n="[placeholder]LOGIN/LABEL_PASSWORD" />
|
data-i18n="[placeholder]LOGIN/LABEL_PASSWORD" />
|
||||||
<span class="add-on" tabindex="-1"
|
<span class="add-on fontastic">🔑</span>
|
||||||
data-bind="command: submitCommand" data-i18n="[title]LOGIN/BUTTON_LOGIN">
|
</div>
|
||||||
<i class="fontastic" data-bind="visible: '' === password()">🔑</i>
|
</div>
|
||||||
<button type="submit" class="btn-submit-icon-wrp login-submit-icon fontastic" data-bind="visible: '' !== password()">❯</button>
|
<div class="controls">
|
||||||
</span>
|
<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>
|
</div>
|
||||||
<div id="plugin-Login-BottomControlGroup"></div>
|
<div id="plugin-Login-BottomControlGroup"></div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
data-bind="submit: submitForm, css: {'errorAnimated': formError, 'submitting': submitRequest()}">
|
data-bind="submit: submitForm, css: {'errorAnimated': formError, 'submitting': submitRequest()}">
|
||||||
<div class="controls" data-bind="css: {'error': emailError}">
|
<div class="controls" data-bind="css: {'error': emailError}">
|
||||||
<div class="input-append">
|
<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"
|
autofocus="" autocomplete="email" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||||
data-bind="textInput: email, disable: submitRequest"
|
data-bind="textInput: email, disable: submitRequest"
|
||||||
data-i18n="[placeholder]GLOBAL/EMAIL" />
|
data-i18n="[placeholder]GLOBAL/EMAIL" />
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="controls" data-bind="css: {'error': passwordError}">
|
<div class="controls" data-bind="css: {'error': passwordError}">
|
||||||
<div class="input-append">
|
<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"
|
autocomplete="current-password" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||||
data-bind="textInput: password, disable: submitRequest"
|
data-bind="textInput: password, disable: submitRequest"
|
||||||
data-i18n="[placeholder]GLOBAL/PASSWORD" />
|
data-i18n="[placeholder]GLOBAL/PASSWORD" />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue