Use signed URL to simplify resetting password

This commit is contained in:
Pig Fang 2018-07-18 11:04:34 +08:00
parent fa7bc18364
commit 3c24a166e8
7 changed files with 37 additions and 186 deletions

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use URL;
use Log; use Log;
use Mail; use Mail;
use View; use View;
@ -181,13 +182,10 @@ class AuthController extends Controller
if (! $user) if (! $user)
return json(trans('auth.forgot.unregistered'), 1); return json(trans('auth.forgot.unregistered'), 1);
// Generate token for password resetting $url = URL::temporarySignedRoute('auth.reset', now()->addHour(), ['uid' => $user->uid]);
$token = base64_encode($user->getToken().substr(time(), 4, 6).str_random(16));
try { try {
Mail::to($request->input('email'))->send(new ForgotPassword($user->uid, $token)); Mail::to($request->input('email'))->send(new ForgotPassword($url));
Log::info("[Password Reset] Mail has been sent to [{$request->input('email')}] with token [$token]");
} catch (\Exception $e) { } catch (\Exception $e) {
// Write the exception to log // Write the exception to log
app(\Illuminate\Foundation\Exceptions\Handler::class)->report($e); app(\Illuminate\Foundation\Exceptions\Handler::class)->report($e);
@ -200,63 +198,18 @@ class AuthController extends Controller
return json(trans('auth.mail.success'), 0); return json(trans('auth.mail.success'), 0);
} }
public function reset(UserRepository $users, Request $request) public function reset($uid, UserRepository $users)
{ {
if ($request->has('uid') && $request->has('token')) { return view('auth.reset')->with('user', $users->get($uid));
// Get user instance from repository
$user = $users->get($request->input('uid'));
if (! $user)
return redirect('auth/forgot')->with('msg', trans('auth.reset.invalid'));
// Unpack to get user token & timestamp
$decoded = base64_decode($request->input('token'));
$token = substr($decoded, 0, -22);
$timestamp = substr($decoded, strlen($token), 6);
if ($user->getToken() != $token) {
return redirect('auth/forgot')->with('msg', trans('auth.reset.invalid'));
}
// More than 1 hour
if ((substr(time(), 4, 6) - $timestamp) > 3600) {
return redirect('auth/forgot')->with('msg', trans('auth.reset.expired'));
}
return view('auth.reset')->with('user', $user);
} else {
return redirect('auth/login')->with('msg', trans('auth.check.anonymous'));
}
} }
public function handleReset(Request $request, UserRepository $users) public function handleReset($uid, Request $request, UserRepository $users)
{ {
$this->validate($request, [ $validated = $this->validate($request, [
'uid' => 'required|integer',
'password' => 'required|min:8|max:32', 'password' => 'required|min:8|max:32',
'token' => 'required',
]); ]);
$decoded = base64_decode($request->input('token')); $users->get($uid)->changePasswd($validated['password']);
$token = substr($decoded, 0, -22);
$timestamp = intval(substr($decoded, strlen($token), 6));
$user = $users->get($request->input('uid'));
if (! $user)
return json(trans('auth.reset.invalid'), 1);
if ($user->getToken() != $token) {
return json(trans('auth.reset.invalid'), 1);
}
// More than 1 hour
if ((intval(substr(time(), 4, 6)) - $timestamp) > 3600) {
return json(trans('auth.reset.expired'), 1);
}
$users->get($request->input('uid'))->changePasswd($request->input('password'));
Log::info("[Password Reset] Password of user [{$request->input('uid')}] has been changed");
return json(trans('auth.reset.success'), 0); return json(trans('auth.reset.success'), 0);
} }

View File

@ -49,5 +49,6 @@ class Kernel extends HttpKernel
'admin' => \App\Http\Middleware\CheckAdministrator::class, 'admin' => \App\Http\Middleware\CheckAdministrator::class,
'player' => \App\Http\Middleware\CheckPlayerExist::class, 'player' => \App\Http\Middleware\CheckPlayerExist::class,
'setup' => \App\Http\Middleware\CheckInstallation::class, 'setup' => \App\Http\Middleware\CheckInstallation::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
]; ];
} }

View File

@ -19,13 +19,12 @@ class ForgotPassword extends Mailable
/** /**
* Create a new message instance. * Create a new message instance.
* *
* @param int $uid * @param string $url
* @param string $token
* @return void * @return void
*/ */
public function __construct(int $uid, string $token) public function __construct(string $url)
{ {
$this->reset_url = option('site_url')."/auth/reset?uid=$uid&token=$token"; $this->reset_url = $url;
} }
/** /**

View File

@ -327,7 +327,9 @@ describe('tests for "reset" module', () => {
const url = jest.fn(path => path); const url = jest.fn(path => path);
const swal = jest.fn().mockReturnValue(Promise.resolve()); const swal = jest.fn().mockReturnValue(Promise.resolve());
const showMsg = jest.fn(); const showMsg = jest.fn();
const getQueryString = jest.fn().mockReturnValue('token'); const getQueryString = jest.fn()
.mockReturnValueOnce('expires')
.mockReturnValueOnce('signature');
const showAjaxError = jest.fn(); const showAjaxError = jest.fn();
window.fetch = fetch; window.fetch = fetch;
window.trans = trans; window.trans = trans;
@ -377,15 +379,14 @@ describe('tests for "reset" module', () => {
$('#confirm-pwd').val('password'); $('#confirm-pwd').val('password');
await $('button').click(); await $('button').click();
expect(getQueryString).toBeCalledWith('token'); expect(getQueryString.mock.calls[0][0]).toBe('expires');
expect(getQueryString.mock.calls[1][0]).toBe('signature');
expect(fetch).toBeCalledWith(expect.objectContaining({ expect(fetch).toBeCalledWith(expect.objectContaining({
type: 'POST', type: 'POST',
url: 'auth/reset', url: 'auth/reset/1?expires=expires&signature=signature',
dataType: 'json', dataType: 'json',
data: { data: {
uid: '1', password: 'password'
password: 'password',
token: 'token'
} }
})); }));
expect($('button').html()).toBe( expect($('button').html()).toBe(

View File

@ -2,11 +2,9 @@
$('#reset-button').click(e => { $('#reset-button').click(e => {
e.preventDefault(); e.preventDefault();
const data = { const data = {
uid: $('#uid').val(),
password: $('#password').val(), password: $('#password').val(),
token: getQueryString('token')
}; };
(function validate({ password }, callback) { (function validate({ password }, callback) {
@ -29,9 +27,9 @@ $('#reset-button').click(e => {
try { try {
const { errno, msg } = await fetch({ const { errno, msg } = await fetch({
type: 'POST', type: 'POST',
url: url('auth/reset'), url: `${url('auth/reset')}/${$('#uid').val()}?expires=${getQueryString('expires')}&signature=${getQueryString('signature')}`,
dataType: 'json', dataType: 'json',
data: data, data,
beforeSend: () => { beforeSend: () => {
$('#reset-button').html( $('#reset-button').html(
'<i class="fa fa-spinner fa-spin"></i> ' + trans('auth.resetting') '<i class="fa fa-spinner fa-spin"></i> ' + trans('auth.resetting')

View File

@ -23,10 +23,10 @@ Route::group(['prefix' => 'auth'], function ()
{ {
Route::group(['middleware' => 'guest'], function () Route::group(['middleware' => 'guest'], function ()
{ {
Route::view('/login', 'auth.login'); Route::view('/login', 'auth.login');
Route::get ('/register', 'AuthController@register'); Route::get ('/register', 'AuthController@register');
Route::get ('/forgot', 'AuthController@forgot'); Route::get ('/forgot', 'AuthController@forgot');
Route::get ('/reset', 'AuthController@reset'); Route::get ('/reset/{uid}', 'AuthController@reset')->name('auth.reset')->middleware('signed');
}); });
Route::any('/logout', 'AuthController@logout'); Route::any('/logout', 'AuthController@logout');
@ -36,7 +36,7 @@ Route::group(['prefix' => 'auth'], function ()
Route::post('/register', 'AuthController@handleRegister'); Route::post('/register', 'AuthController@handleRegister');
Route::post('/forgot', 'AuthController@handleForgot'); Route::post('/forgot', 'AuthController@handleForgot');
Route::post('/reset', 'AuthController@handleReset'); Route::post('/reset/{uid}', 'AuthController@handleReset')->middleware('signed');
}); });
/** /**

View File

@ -441,11 +441,6 @@ class AuthControllerTest extends TestCase
'msg' => trans('auth.forgot.unregistered') 'msg' => trans('auth.forgot.unregistered')
]); ]);
$uid = $user->uid;
$token = base64_encode(
$user->getToken().substr(time(), 4, 6).str_random(16)
);
$url = Option::get('site_url')."/auth/reset?uid=$uid&token=$token";
$this->postJson('/auth/forgot', [ $this->postJson('/auth/forgot', [
'email' => $user->email, 'email' => $user->email,
'captcha' => 'a' 'captcha' => 'a'
@ -453,8 +448,8 @@ class AuthControllerTest extends TestCase
'errno' => 0, 'errno' => 0,
'msg' => trans('auth.mail.success') 'msg' => trans('auth.mail.success')
])->assertSessionHas('last_mail_time'); ])->assertSessionHas('last_mail_time');
Mail::assertSent(ForgotPassword::class, function ($mail) use ($url, $user) { Mail::assertSent(ForgotPassword::class, function ($mail) use ($user) {
return stristr($url, $mail->reset_url) == 0 && $mail->hasTo($user->email); return $mail->hasTo($user->email);
}); });
// Should handle exception when sending email // Should handle exception when sending email
@ -476,59 +471,19 @@ class AuthControllerTest extends TestCase
{ {
$user = factory(User::class)->create(); $user = factory(User::class)->create();
// Should be redirected if `uid` or `token` is empty $this->get(
$this->get('/auth/reset') URL::temporarySignedRoute('auth.reset', now()->addHour(), ['uid' => $user->uid])
->assertRedirect('/auth/login'); )->assertSuccessful();
// Should be redirected if `uid` is invalid
$this->get('/auth/reset?uid=-1&token=nothing')
->assertRedirect('/auth/forgot');
// Should be redirected if `token` is invalid
$this->get('/auth/reset?uid=' . $user->uid . '&token=nothing')
->assertRedirect('/auth/forgot');
// Should be redirected if expired
$token = base64_encode(
$user->getToken().substr(time() - 60 * 60 * 2, 4, 6).str_random(16)
);
$this->get('/auth/reset?uid=' . $user->uid . '&token=' . $token)
->assertRedirect('/auth/forgot');
// Success
$token = base64_encode(
$user->getToken().substr(time(), 4, 6).str_random(16)
);
$this->get('/auth/reset?uid=' . $user->uid . '&token=' . $token);
} }
public function testHandleReset() public function testHandleReset()
{ {
$user = factory(User::class)->create(); $user = factory(User::class)->create();
$url = URL::temporarySignedRoute('auth.reset', now()->addHour(), ['uid' => $user->uid]);
// Should return a warning if `uid` is empty
$this->postJson('/auth/reset', [], [
'X-Requested-With' => 'XMLHttpRequest'
])->assertJson([
'errno' => 1,
'msg' => trans('validation.required', ['attribute' => 'uid'])
]);
// Should return a warning if `uid` is not an integer
$this->postJson('/auth/reset', [
'uid' => 'string'
], [
'X-Requested-With' => 'XMLHttpRequest'
])->assertJson([
'errno' => 1,
'msg' => trans('validation.integer', ['attribute' => 'uid'])
]);
// Should return a warning if `password` is empty // Should return a warning if `password` is empty
$this->postJson( $this->postJson(
'/auth/reset', [ $url, [], [
'uid' => $user->uid
], [
'X-Requested-With' => 'XMLHttpRequest' 'X-Requested-With' => 'XMLHttpRequest'
])->assertJson([ ])->assertJson([
'errno' => 1, 'errno' => 1,
@ -537,8 +492,7 @@ class AuthControllerTest extends TestCase
// Should return a warning if `password` is too short // Should return a warning if `password` is too short
$this->postJson( $this->postJson(
'/auth/reset', [ $url, [
'uid' => $user->uid,
'password' => '123' 'password' => '123'
], [ ], [
'X-Requested-With' => 'XMLHttpRequest' 'X-Requested-With' => 'XMLHttpRequest'
@ -549,8 +503,7 @@ class AuthControllerTest extends TestCase
// Should return a warning if `password` is too long // Should return a warning if `password` is too long
$this->postJson( $this->postJson(
'/auth/reset', [ $url, [
'uid' => $user->uid,
'password' => str_random(33) 'password' => str_random(33)
], [ ], [
'X-Requested-With' => 'XMLHttpRequest' 'X-Requested-With' => 'XMLHttpRequest'
@ -559,64 +512,10 @@ class AuthControllerTest extends TestCase
'msg' => trans('validation.max.string', ['attribute' => 'password', 'max' => 32]) 'msg' => trans('validation.max.string', ['attribute' => 'password', 'max' => 32])
]); ]);
// Should be forbidden if `token` is missing
$this->postJson(
'/auth/reset', [
'uid' => $user->uid,
'password' => '12345678'
], ['X-Requested-With' => 'XMLHttpRequest'])->assertJson([
'errno' => 1,
'msg' => trans('validation.required', ['attribute' => 'token'])
]);
// Should be forbidden if expired
$token = base64_encode(
$user->getToken().substr(time() - 60 * 60 * 2, 4, 6).str_random(16)
);
$this->postJson(
'/auth/reset', [
'uid' => $user->uid,
'password' => '12345678',
'token' => $token
])->assertJson([
'errno' => 1,
'msg' => trans('auth.reset.expired')
]);
// Should return a warning if the user is not existed
$token = base64_encode(
$user->getToken().substr(time(), 4, 6).str_random(16)
);
$this->postJson(
'/auth/reset', [
'uid' => -1,
'password' => '12345678',
'token' => $token
])->assertJson([
'errno' => 1,
'msg' => trans('auth.reset.invalid')
]);
// Should be forbidden if `token` is invalid
$this->postJson(
'/auth/reset', [
'uid' => $user->uid,
'password' => '12345678',
'token' => 'invalid'
])->assertJson([
'errno' => 1,
'msg' => trans('auth.reset.invalid')
]);
// Success // Success
$token = base64_encode(
$user->getToken().substr(time(), 4, 6).str_random(16)
);
$this->postJson( $this->postJson(
'/auth/reset', [ $url, [
'uid' => $user->uid,
'password' => '12345678', 'password' => '12345678',
'token' => $token
])->assertJson([ ])->assertJson([
'errno' => 0, 'errno' => 0,
'msg' => trans('auth.reset.success') 'msg' => trans('auth.reset.success')