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(