diff --git a/app/Http/Controllers/Api/V1/Auth/ResetPasswordController.php b/app/Http/Controllers/Api/V1/Auth/ResetPasswordController.php
index 29d2de0..fdc387d 100755
--- a/app/Http/Controllers/Api/V1/Auth/ResetPasswordController.php
+++ b/app/Http/Controllers/Api/V1/Auth/ResetPasswordController.php
@@ -2,9 +2,12 @@
namespace App\Http\Controllers\Api\V1\Auth;
+use App\User;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
+use Illuminate\Contracts\Auth\Guard;
+use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\Controller;
class ResetPasswordController extends Controller
@@ -16,8 +19,42 @@ class ResetPasswordController extends Controller
*
* @return \Illuminate\Http\JsonResponse
*/
- public function reset(Request $request) : JsonResponse
+ public function reset(Request $request, string $token) : JsonResponse
{
- return response()->json('Resetting...');
+ $request->validate([
+ 'password' => 'required|min:8|confirmed|pwned:100'
+ ]);
+
+ $password_reset = DB::table('password_resets')
+ ->where('token', $token)
+ ->latest()
+ ->first();
+
+ if (! $password_reset) {
+ return response()->json('Reset link invalid!', 422);
+ }
+
+ $user = User::where('email', $password_reset->email)->first();
+
+ if (! $password_reset) {
+ return response()->json('User does not exist!', 422);
+ }
+
+ $user->password = bcrypt($request->input('password'));
+ $user->update();
+
+ return response()->json(
+ _token_payload($this->guard()->login($user))
+ );
+ }
+
+ /**
+ * Get the guard to be used during authentication.
+ *
+ * @return \Illuminate\Contracts\Auth\Guard
+ */
+ protected function guard() : Guard
+ {
+ return Auth::guard('api');
}
}
diff --git a/app/Http/Controllers/Api/V1/Auth/SessionsController.php b/app/Http/Controllers/Api/V1/Auth/SessionsController.php
index 25b1317..758f37d 100755
--- a/app/Http/Controllers/Api/V1/Auth/SessionsController.php
+++ b/app/Http/Controllers/Api/V1/Auth/SessionsController.php
@@ -103,11 +103,7 @@ protected function respondWithToken($authToken) : JsonResponse
$this->saveAuthToken($authToken, $user);
- return response()->json([
- 'auth_token' => $authToken,
- 'token_type' => 'bearer',
- 'expires_in' => $this->guard()->factory()->getTTL() * 60
- ]);
+ return response()->json(_token_payload($authToken));
}
/**
diff --git a/app/Utils/Helper.php b/app/Utils/Helper.php
index aa98839..b300f40 100755
--- a/app/Utils/Helper.php
+++ b/app/Utils/Helper.php
@@ -18,6 +18,24 @@ function _asset(string $subPath) : string
}
}
+if (! function_exists('_token_payload')) {
+ /**
+ * Get the token bearer payload.
+ *
+ * @param string $authToken
+ *
+ * @return array
+ */
+ function _token_payload(string $authToken) : array
+ {
+ return [
+ 'auth_token' => $authToken,
+ 'token_type' => 'bearer',
+ 'expires_in' => auth()->guard('api')->factory()->getTTL() * 60
+ ];
+ }
+}
+
if (! function_exists('_test_user')) {
/**
* Login and get the then authenticated user.
@@ -40,7 +58,7 @@ function _test_user()
*
* @return string $operator
*/
- function _to_sql_operator($keyword)
+ function _to_sql_operator($keyword) : string
{
switch ($keyword) {
case 'eqs':
diff --git a/composer.json b/composer.json
index e09144f..4c250c1 100755
--- a/composer.json
+++ b/composer.json
@@ -20,7 +20,8 @@
"laravel/framework": "5.8.*",
"laravel/telescope": "^2.0",
"laravel/tinker": "^1.0",
- "tymon/jwt-auth": "dev-develop#34d8e48 as 1.0.0-rc.3.2"
+ "tymon/jwt-auth": "dev-develop#34d8e48 as 1.0.0-rc.3.2",
+ "valorin/pwned-validator": "^1.2"
},
"require-dev": {
"beyondcode/laravel-dump-server": "^1.0",
diff --git a/composer.lock b/composer.lock
index 3a04a10..8250391 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "c55d2e5951514a88fd7af3502f90fd11",
+ "content-hash": "a606a7c84bd4b62843a706b1f5621aa0",
"packages": [
{
"name": "beyondcode/laravel-self-diagnosis",
@@ -3428,6 +3428,51 @@
],
"time": "2019-03-14T20:29:20+00:00"
},
+ {
+ "name": "valorin/pwned-validator",
+ "version": "1.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/valorin/pwned-validator.git",
+ "reference": "5344c31ed9072692934fde5a183ce29f3c049ab1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/valorin/pwned-validator/zipball/5344c31ed9072692934fde5a183ce29f3c049ab1",
+ "reference": "5344c31ed9072692934fde5a183ce29f3c049ab1",
+ "shasum": ""
+ },
+ "require": {
+ "ext-curl": "*",
+ "illuminate/support": "~5.5",
+ "php": ">=7.1.3"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Valorin\\Pwned\\ServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Valorin\\Pwned\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Stephen Rees-Carter",
+ "email": "stephen@rees-carter.net"
+ }
+ ],
+ "description": "Super simple Laravel Validator for checking password via the Pwned Passwords service of Have I Been Pwned",
+ "time": "2019-03-15T18:34:37+00:00"
+ },
{
"name": "vlucas/phpdotenv",
"version": "v3.3.3",
diff --git a/resources/js/views/auth/passwords/Request.js b/resources/js/views/auth/passwords/Request.js
index 56d2111..d29a786 100755
--- a/resources/js/views/auth/passwords/Request.js
+++ b/resources/js/views/auth/passwords/Request.js
@@ -2,7 +2,14 @@ import React, { Component } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Formik, Form, withFormik } from 'formik';
import * as Yup from 'yup';
-import { Grid, TextField, Button, Link, withStyles } from '@material-ui/core';
+import {
+ Button,
+ Grid,
+ Link,
+ TextField,
+ Typography,
+ withStyles,
+} from '@material-ui/core';
import * as NavigationUtils from '../../../utils/Navigation';
import * as UrlUtils from '../../../utils/URL';
@@ -43,10 +50,10 @@ class PasswordRequest extends Component {
type: 'success',
title: 'Link Sent',
body: (
-
+
Check your email to reset your account.
Thank you.
-
+
),
action: () => history.push(`/signin?username=${email}`),
},
@@ -59,10 +66,10 @@ class PasswordRequest extends Component {
type: 'error',
title: 'Something went wrong',
body: (
-
+
Oops? Something went wrong here.
Please try again.
-
+
),
action: () => window.location.reload(),
},
diff --git a/resources/js/views/auth/passwords/Reset.js b/resources/js/views/auth/passwords/Reset.js
index 48fff29..37444ce 100755
--- a/resources/js/views/auth/passwords/Reset.js
+++ b/resources/js/views/auth/passwords/Reset.js
@@ -1,6 +1,6 @@
import React, { Component } from 'react';
import { Link as RouterLink } from 'react-router-dom';
-import { Formik, Form, withFormik } from 'formik';
+import { Formik, Form } from 'formik';
import * as Yup from 'yup';
import {
@@ -26,6 +26,7 @@ class PasswordReset extends Component {
state = {
loading: false,
message: {},
+ email: '',
showPassword: false,
showPasswordConfirmation: false,
};
@@ -52,21 +53,59 @@ class PasswordReset extends Component {
*
* @return {undefined}
*/
- handleSubmit = async (values, { setSubmitting }) => {
+ handleSubmit = async (values, { setSubmitting, setErrors }) => {
setSubmitting(false);
+
+ this.setState({ loading: true });
+
+ try {
+ const { match, pageProps } = this.props;
+ const { token } = match.params;
+
+ const response = await axios.patch(
+ `api/v1/auth/password/reset/${token}`,
+ values,
+ );
+
+ await pageProps.authenticate(JSON.stringify(response.data));
+
+ this.setState({ loading: false });
+ } catch (error) {
+ if (!error.response) {
+ throw new Error('Unknown error');
+ }
+
+ const { errors } = error.response.data;
+
+ if (errors) {
+ setErrors(errors);
+ }
+
+ this.setState({ loading: false });
+ }
};
+ componentDidMount() {
+ const { location } = this.props;
+
+ const queryParams = UrlUtils._queryParams(location.search);
+
+ if (!queryParams.hasOwnProperty('email')) {
+ return;
+ }
+
+ this.setState({
+ email: queryParams.email,
+ });
+ }
+
render() {
- const { classes, location } = this.props;
- const email = UrlUtils._queryParams(location.search).hasOwnProperty(
- 'email',
- )
- ? UrlUtils._queryParams(location.search).email
- : '';
+ const { classes } = this.props;
const {
loading,
message,
+ email,
showPassword,
showPasswordConfirmation,
} = this.state;
@@ -265,4 +304,4 @@ const styles = theme => ({
},
});
-export default withStyles(styles)(withFormik({})(PasswordReset));
+export default withStyles(styles)(PasswordReset);
diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php
index 35e5e88..43daff3 100755
--- a/resources/lang/en/validation.php
+++ b/resources/lang/en/validation.php
@@ -92,6 +92,7 @@
'not_regex' => 'The :attribute format is invalid.',
'numeric' => 'The :attribute must be a number.',
'present' => 'The :attribute field must be present.',
+ 'pwned' => 'The :attribute is weak, please enter a strong password.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_if' => 'The :attribute field is required when :other is :value.',
diff --git a/resources/lang/fil/validation.php b/resources/lang/fil/validation.php
index 35e5e88..43daff3 100755
--- a/resources/lang/fil/validation.php
+++ b/resources/lang/fil/validation.php
@@ -92,6 +92,7 @@
'not_regex' => 'The :attribute format is invalid.',
'numeric' => 'The :attribute must be a number.',
'present' => 'The :attribute field must be present.',
+ 'pwned' => 'The :attribute is weak, please enter a strong password.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_if' => 'The :attribute field is required when :other is :value.',
diff --git a/routes/api.php b/routes/api.php
index 109cf9a..65469b7 100755
--- a/routes/api.php
+++ b/routes/api.php
@@ -25,7 +25,7 @@
Route::name('password.')->prefix('password')->group(function () {
Route::post('request', 'ForgotPasswordController@sendResetLinkEmail')->name('request');
- Route::post('reset/{token}', 'ResetPasswordController@reset')->name('reset');
+ Route::patch('reset/{token}', 'ResetPasswordController@reset')->name('reset');
});
});
diff --git a/tests/Feature/Api/V1/Auth/ForgotPasswordTest.php b/tests/Feature/Api/V1/Auth/ForgotPasswordTest.php
index 9d71dba..dd367cf 100644
--- a/tests/Feature/Api/V1/Auth/ForgotPasswordTest.php
+++ b/tests/Feature/Api/V1/Auth/ForgotPasswordTest.php
@@ -3,9 +3,30 @@
namespace Tests\Feature\Api\V1\Auth;
use App\User;
+use App\Jobs\ProcessPasswordResetRequest;
+use Illuminate\Support\Facades\Bus;
use Tests\Feature\Api\V1\BaseTest;
class ForgotPasswordTest extends BaseTest
{
+ /** @test */
+ public function a_user_can_request_for_password_reset_link()
+ {
+ // The user that is requesting for the Password Reset Link.
+ $user = User::first();
-}
\ No newline at end of file
+ // The response body that should be sent alongside the request.
+ $body = [
+ 'email' => $user->email
+ ];
+
+ // Assuming that the Password Reset Request is processed,
+ // It must return a 200 response status and then,
+ // It must return a response body containing: `Sending...`.
+ $this->post(route('api.v1.auth.password.request'), $body)
+ ->assertStatus(200)
+ ->assertSee('Sending...');
+
+ // TODO: Assert if Jobs are dispatched & Notifications are sent.
+ }
+}
diff --git a/tests/Feature/Api/V1/Auth/ResetPasswordTest.php b/tests/Feature/Api/V1/Auth/ResetPasswordTest.php
index d781f03..1ca8ded 100644
--- a/tests/Feature/Api/V1/Auth/ResetPasswordTest.php
+++ b/tests/Feature/Api/V1/Auth/ResetPasswordTest.php
@@ -3,9 +3,43 @@
namespace Tests\Feature\Api\V1\Auth;
use App\User;
+use Illuminate\Support\Facades\DB;
use Tests\Feature\Api\V1\BaseTest;
class ResetPasswordTest extends BaseTest
{
+ /** @test */
+ public function a_user_can_reset_their_password()
+ {
+ // The user that will reset their password.
+ $user = User::first();
-}
\ No newline at end of file
+ // Create a dummy password reset data
+ DB::table('password_resets')->insert([
+ 'email' => $user->email,
+ 'token' => str_random(64),
+ 'created_at' => now()
+ ]);
+
+ $password_reset = DB::table('password_resets')
+ ->where('email', $user->email)
+ ->first();
+
+ $password = 'tellmewhat';
+
+ // The response body that should be sent alongside the request.
+ $body = [
+ 'password' => $password,
+ 'password_confirmation' => $password
+ ];
+
+ // Assuming that their password has been reset,
+ // It must return a 200 response status and then,
+ // It must return a response body containing a valid JSON structure.
+ $this->patch(route('api.v1.auth.password.reset', $password_reset->token), $body)
+ ->assertStatus(200)
+ ->assertJsonStructure([
+ 'auth_token', 'token_type', 'expires_in'
+ ]);
+ }
+}
diff --git a/tests/Feature/Api/V1/UsersTest.php b/tests/Feature/Api/V1/UsersTest.php
index 0aadc81..909d94a 100644
--- a/tests/Feature/Api/V1/UsersTest.php
+++ b/tests/Feature/Api/V1/UsersTest.php
@@ -12,10 +12,10 @@ class UsersTest extends BaseTest
/** @test */
public function a_user_can_list_users()
{
- // The payload that should be sent alongside the request.
- $payload = array_merge($this->getDefaultPayload(), []);
+ // The response body that should be sent alongside the request.
+ $body = array_merge($this->getDefaultPayload(), []);
- $this->get(route('api.v1.users.index'), $payload)->assertStatus(200);
+ $this->get(route('api.v1.users.index'), $body)->assertStatus(200);
}
/** @test */
@@ -36,13 +36,13 @@ public function a_user_can_create_a_user()
'username' => $this->faker->userName,
];
- // The payload that should be sent alongside the request.
- $payload = array_merge($this->getDefaultPayload(), []);
+ // The response body that should be sent alongside the request.
+ $body = array_merge($this->getDefaultPayload(), []);
// Assuming that the user is created through the test data,
// It must return a 201 response status and then,
// It must return a response body consisting our test data, if not all.
- $this->post(route('api.v1.users.store'), array_merge($attributes, $payload))
+ $this->post(route('api.v1.users.store'), array_merge($attributes, $body))
->assertStatus(201)
->assertJson($attributes);
@@ -56,16 +56,16 @@ public function a_user_can_create_a_user()
/** @test */
public function a_user_can_view_a_user()
{
- // The payload that should be sent alongside the request.
- $payload = array_merge($this->getDefaultPayload(), []);
-
// The user to be shown.
$user = User::first();
+ // The response body that should be sent alongside the request.
+ $body = array_merge($this->getDefaultPayload(), []);
+
// Assuming that a user is found,
// It must return a 200 response status and then,
// It must be found as is in the JSON response.
- $this->get(route('api.v1.users.show', $user), $payload)
+ $this->get(route('api.v1.users.show', $user), $body)
->assertStatus(200)
->assertExactJson($user->toArray());
}
@@ -73,18 +73,18 @@ public function a_user_can_view_a_user()
/** @test */
public function a_user_can_update_a_user()
{
- // The payload that should be sent alongside the request.
- $payload = array_merge($this->getDefaultPayload(), []);
-
// The user to be updated.
$user = User::first();
$user->address = $this->faker->address;
$user->update();
+ // The response body that should be sent alongside the request.
+ $body = array_merge($this->getDefaultPayload(), []);
+
// Assuming that the user is updated,
// It must return a 200 response status and then,
// It must be found as is in the JSON response.
- $this->patch(route('api.v1.users.update', $user), $payload)
+ $this->patch(route('api.v1.users.update', $user), $body)
->assertStatus(200)
->assertExactJson($user->toArray());
}
@@ -92,19 +92,19 @@ public function a_user_can_update_a_user()
/** @test */
public function a_user_can_delete_a_user()
{
- // The payload that should be sent alongside the request.
- $payload = array_merge($this->getDefaultPayload(), []);
-
// The user to be deleted.
$user = User::latest()->first();
+ // The response body that should be sent alongside the request.
+ $body = array_merge($this->getDefaultPayload(), []);
+
// Decremented counter.
$decremented = User::count() - 1;
// Assuming that the API will delete one,
// It must return a 200 response status and then,
// It must equal this decremented counter.
- $this->delete(route('api.v1.users.destroy', $user))
+ $this->delete(route('api.v1.users.destroy', $user), $body)
->assertStatus(200)
->assertJsonFragment([
'total' => $decremented
@@ -114,8 +114,6 @@ public function a_user_can_delete_a_user()
/** @test */
public function a_user_can_restore_a_user()
{
- $payload = array_merge($this->getDefaultPayload(), []);
-
// Delete a user, it will then be restored.
$deletedUser = User::latest()->first();
$deletedUser->delete();
@@ -126,10 +124,13 @@ public function a_user_can_restore_a_user()
// Get the deleted user.
$recoverableUser = User::withTrashed()->find($deletedUser->id);
+ // The response body that should be sent alongside the request.
+ $body = array_merge($this->getDefaultPayload(), []);
+
// Assuming that the users count is decremented after deleting one,
// It must return a 200 response status and then,
// It must equal this incremented counter.
- $this->patch(route('api.v1.users.restore', $recoverableUser))
+ $this->patch(route('api.v1.users.restore', $recoverableUser), $body)
->assertStatus(200)
->assertJsonFragment([
'total' => $incremented
@@ -162,14 +163,14 @@ public function a_user_can_destroy_an_avatar()
// Fake an upload so that we could destroy it.
$response = $this->storeAvatar($user);
- // The payload that should be sent alongside the request.
- $payload = array_merge($this->getDefaultPayload());
+ // The response body that should be sent alongside the request.
+ $body = array_merge($this->getDefaultPayload());
// Assuming that the user's avatar is removed,
// It must return a 200 response status and then,
// It must return a response with a user containing
// null upload attributes to indicate that it was completely destroyed.
- $response = $this->delete(route('api.v1.users.avatar.destroy', $user), $payload)
+ $response = $this->delete(route('api.v1.users.avatar.destroy', $user), $body)
->assertStatus(200)
->assertJsonFragment(
array_fill_keys($user->getUploadAttributes(), null)
@@ -190,13 +191,18 @@ public function a_user_can_destroy_an_avatar()
*/
protected function storeAvatar(User $user) : TestResponse
{
- // The payload that should be sent alongside the request.
- $payload = array_merge($this->getDefaultPayload(), [
+ // The response body that should be sent alongside the request.
+ $body = array_merge($this->getDefaultPayload(), [
'avatar' => UploadedFile::fake()->image('avatar.jpg')
]);
+ // Assuming that the fake file has been uploaded,
+ // It must return a 200 response status and then,
+ // It must return a response with a user containing
+ // non-null upload attributes.
return $this->post(
- route('api.v1.users.avatar.store', $user), $payload
+ route('api.v1.users.avatar.store', $user),
+ $body
)
->assertStatus(200)
->assertJsonMissing(