Enhance rate limit for sending password reset email

This commit is contained in:
printempw 2018-07-27 16:47:49 +08:00
parent bcd4b059d5
commit fd5d8a06ce
7 changed files with 86 additions and 20 deletions

View File

@ -177,8 +177,18 @@ class AuthController extends Controller
if (config('mail.driver') == "")
return json(trans('auth.forgot.close'), 1);
if (Session::has('last_mail_time') && (time() - session('last_mail_time')) < 60)
return json(trans('auth.forgot.frequent-mail'), 1);
$rateLimit = 180;
$lastMailCacheKey = sha1('last_mail_'.Utils::getClientIp());
$remain = $rateLimit + Cache::get($lastMailCacheKey, 0) - time();
// Rate limit
if ($remain > 0) {
return json([
'errno' => 2,
'msg' => trans('auth.forgot.frequent-mail'),
'remain' => $remain
]);
}
// Get user instance
$user = $users->get($request->input('email'), 'email');
@ -199,8 +209,6 @@ class AuthController extends Controller
$m->from(config('mail.username'), $site_name);
$m->to($request->input('email'))->subject(trans('auth.forgot.mail.title', ['sitename' => $site_name]));
});
Log::info("[Password Reset] Mail has been sent to [{$request->input('email')}] with token [$token]");
} catch (\Exception $e) {
// Write the exception to log
report($e);
@ -208,7 +216,7 @@ class AuthController extends Controller
return json(trans('auth.forgot.failed', ['msg' => $e->getMessage()]), 2);
}
Session::put('last_mail_time', time());
Cache::put($lastMailCacheKey, time(), 60);
return json(trans('auth.forgot.success'), 0);
}

View File

@ -232,6 +232,8 @@ describe('tests for "register" module', () => {
});
});
jest.useFakeTimers();
describe('tests for "forgot" module', () => {
const modulePath = '../auth/forgot';
@ -263,10 +265,20 @@ describe('tests for "forgot" module', () => {
<input id="email" />
<div id="captcha-form"></div>
<input id="captcha" />
<button id="forgot-button"></button>
<button id="forgot-button" data-remain="60"></button>
`;
require(modulePath);
require(modulePath)();
expect(setInterval).toHaveBeenCalledTimes(1);
jest.runTimersToTime(1000);
$('button').click();
expect($('button').prop('disabled')).toBe(true);
expect(fetch).not.toBeCalled();
jest.runTimersToTime(60000);
expect($('button').html()).toBe('auth.send');
expect($('button').prop('disabled')).toBe(false);
$('button').click();
expect(trans).toBeCalledWith('auth.emptyEmail');
@ -294,7 +306,7 @@ describe('tests for "forgot" module', () => {
captcha: 'captcha'
}
}));
expect($('button').html()).toBe('auth.send');
expect($('button').html()).toEqual(expect.stringContaining('auth.send'));
expect($('button').prop('disabled')).toBe(true);
expect(showMsg).toBeCalledWith('success', 'success');
@ -310,6 +322,8 @@ describe('tests for "forgot" module', () => {
});
});
jest.useRealTimers();
describe('tests for "reset" module', () => {
const modulePath = '../auth/reset';

View File

@ -24,7 +24,7 @@ $('#forgot-button').click(e => {
}
})(data, async () => {
try {
const { errno, msg } = await fetch({
const { errno, msg, remain } = await fetch({
type: 'POST',
url: url('auth/forgot'),
dataType: 'json',
@ -32,20 +32,55 @@ $('#forgot-button').click(e => {
beforeSend: () => {
$('#forgot-button').html(
'<i class="fa fa-spinner fa-spin"></i> ' + trans('auth.sending')
).prop('disabled', 'disabled');
).prop('disabled', true);
}
});
if (errno === 0) {
showMsg(msg, 'success');
$('#forgot-button').html(trans('auth.send')).prop('disabled', 'disabled');
showRemainTimeIndicator(180);
} else {
showMsg(msg, 'warning');
refreshCaptcha();
$('#forgot-button').html(trans('auth.send')).prop('disabled', '');
if (remain) {
showRemainTimeIndicator(remain);
} else {
$('#forgot-button').html(trans('auth.send')).prop('disabled', false);
}
}
} catch (error) {
showAjaxError(error);
$('#forgot-button').html(trans('auth.send')).prop('disabled', '');
$('#forgot-button').html(trans('auth.send')).prop('disabled', false);
}
});
});
function showRemainTimeIndicator(seconds, intervalID) {
// Get remain time from elem data if not specified
if (seconds === undefined) {
seconds = $('#forgot-button').data('remain');
}
if (seconds > 0) {
$('#forgot-button').html(`${trans('auth.send')} (${seconds})`).prop('disabled', true);
} else {
$('#forgot-button').html(trans('auth.send')).prop('disabled', false);
// Stop timer
if (intervalID) clearInterval(intervalID);
}
// Create timer for decreasing remain time by second
if (! intervalID) {
const intervalID = window.setInterval(function () {
showRemainTimeIndicator(--seconds, intervalID);
}, 1000);
}
}
// Start timer
$(document).ready(() => showRemainTimeIndicator());
if (process.env.NODE_ENV === 'test') {
module.exports = showRemainTimeIndicator;
}

View File

@ -29,7 +29,7 @@ forgot:
message: We will send you an E-mail to verify.
login-link: I do remember it
close: Password resetting is not available now.
frequent-mail: You click the send button too fast. Wait for 60 secs, guy.
frequent-mail: You click the send button too fast. Wait for some minutes, guy.
unregistered: The email address is not registered.
success: Mail sent, please check your inbox. The link will be expired in 1 hour.
failed: Fail to send mail, detailed message :msg

View File

@ -29,7 +29,7 @@ forgot:
message: 我们将会向您发送一封验证邮件
login-link: 我又想起来了
close: 本站已关闭重置密码功能
frequent-mail: 你邮件发送得太频繁啦,过 60 秒后再点发送吧
frequent-mail: 你邮件发送得太频繁啦,过会儿再点发送吧
unregistered: 该邮箱尚未注册
success: 邮件已发送,一小时内有效,请注意查收。
failed: 邮件发送失败,详细信息::msg

View File

@ -42,8 +42,11 @@
<a href="{{ url('auth/login') }}" class="text-center">{{ trans('auth.forgot.login-link') }}</a>
</div>
<!-- /.col -->
<?php $remain = 180 + Cache::get(sha1('last_mail_'.Utils::getClientIp()), 0) - time(); ?>
<div class="col-xs-4">
<button id="forgot-button" class="btn btn-primary btn-block btn-flat">{{ trans('auth.forgot.button') }}</button>
<button id="forgot-button" class="btn btn-primary btn-block btn-flat" data-remain="{{ $remain }}">
{{ trans('auth.forgot.button') }}
</button>
</div>
<!-- /.col -->
</div>

View File

@ -425,16 +425,21 @@ class AuthControllerTest extends TestCase
]);
config(['mail.driver' => 'smtp']);
$lastMailCacheKey = sha1('last_mail_'.Utils::getClientIp());
// Should be forbidden if sending email frequently
$this->withSession(['last_mail_time' => time()])->post('/auth/forgot', [
$this->withCache([
$lastMailCacheKey => time()
])->post('/auth/forgot', [
'captcha' => 'a'
])->seeJson([
'errno' => 1,
'errno' => 2,
'msg' => trans('auth.forgot.frequent-mail')
]);
$this->flushCache();
$this->flushSession();
// Should return a warning if user is not existed
$this->flushSession();
$user = factory(User::class)->create();
$this->withSession(['phrase' => 'a'])->post('/auth/forgot', [
'email' => 'nope@nope.net',
@ -485,7 +490,8 @@ class AuthControllerTest extends TestCase
])->seeJson([
'errno' => 0,
'msg' => trans('auth.forgot.success')
])->assertSessionHas('last_mail_time');
])->assertCacheHas($lastMailCacheKey);
$this->flushCache();
// Should handle exception when sending email
Mail::shouldReceive('send')