From b3968a8181c8413d963db7824029379fdaaccf54 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Tue, 26 May 2026 13:48:22 +0200 Subject: [PATCH] feat: update password --- app/Http/Controllers/AuthController.php | 29 +++++++++- app/Http/Requests/UpdatePasswordRequest.php | 44 ++++++++++++++ lang/en/api.php | 1 + lang/fr/api.php | 1 + routes/api.php | 1 + tests/Feature/UpdatePasswordTest.php | 63 +++++++++++++++++++++ 6 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 app/Http/Requests/UpdatePasswordRequest.php create mode 100644 tests/Feature/UpdatePasswordTest.php diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 5649d62..4082681 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -7,6 +7,7 @@ use App\Http\Requests\ForgotPasswordRequest; use App\Http\Requests\LoginRequest; use App\Http\Requests\RegisterRequest; use App\Http\Requests\ResetPasswordRequest; +use App\Http\Requests\UpdatePasswordRequest; use App\Http\Requests\UpdateUserRequest; use App\Http\Resources\UserResource; use App\Models\User; @@ -255,7 +256,7 @@ class AuthController extends Controller Storage::disk()->delete($user->avatar_url); } - $path = $request->file('avatar')->store('avatars', ); + $path = $request->file('avatar')->store('avatars'); $data['avatar_url'] = $path; } @@ -275,6 +276,32 @@ class AuthController extends Controller return new UserResource($user->fresh()); } + public function updatePassword(UpdatePasswordRequest $request): JsonResponse + { + $user = $request->user(); + + abort_if($user->isSuspended(), 403, __('api.auth.suspended')); + + $currentAccessToken = $user->currentAccessToken(); + + $user->forceFill([ + 'password' => Hash::make($request->validated('password')), + 'remember_token' => Str::random(60), + ])->save(); + + if ($currentAccessToken instanceof PersonalAccessToken) { + $user->tokens() + ->whereKeyNot($currentAccessToken->getKey()) + ->delete(); + } else { + $user->tokens()->delete(); + } + + return response()->json([ + 'message' => __('api.auth.password_updated'), + ]); + } + public function destroy(Request $request): JsonResponse { $user = $request->user(); diff --git a/app/Http/Requests/UpdatePasswordRequest.php b/app/Http/Requests/UpdatePasswordRequest.php new file mode 100644 index 0000000..5cbec1e --- /dev/null +++ b/app/Http/Requests/UpdatePasswordRequest.php @@ -0,0 +1,44 @@ +|string> + */ + public function rules(): array + { + return [ + 'current_password' => ['required', 'string', 'current_password:sanctum'], + 'password' => ['required', 'string', 'min:8', 'confirmed', 'different:current_password'], + ]; + } + + protected function prepareForValidation(): void + { + if ($this->has('currentPassword') && ! $this->has('current_password')) { + $this->merge([ + 'current_password' => $this->input('currentPassword'), + ]); + } + + if ($this->has('passwordConfirmation') && ! $this->has('password_confirmation')) { + $this->merge([ + 'password_confirmation' => $this->input('passwordConfirmation'), + ]); + } + } +} diff --git a/lang/en/api.php b/lang/en/api.php index 21d1f3f..aad3be1 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -13,6 +13,7 @@ return [ 'verification_email_failed' => 'Unable to send the verification email right now. Please try again later.', 'password_reset_link_sent' => 'If an account exists for this address, a password reset link has been sent.', 'password_reset' => 'Password reset successfully.', + 'password_updated' => 'Password updated successfully.', 'logout' => 'Logged out successfully.', 'unauthenticated' => 'Unauthenticated.', 'deleted' => 'Account deleted successfully.', diff --git a/lang/fr/api.php b/lang/fr/api.php index 58c992c..f76d6f5 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -13,6 +13,7 @@ return [ 'verification_email_failed' => "Impossible d'envoyer l'email de vérification pour le moment. Réessaie plus tard.", 'password_reset_link_sent' => 'Si un compte existe avec cette adresse, un lien de réinitialisation vient d’être envoyé.', 'password_reset' => 'Mot de passe réinitialisé avec succès.', + 'password_updated' => 'Mot de passe modifié avec succès.', 'logout' => 'Déconnecté avec succès.', 'unauthenticated' => 'Non authentifié.', 'deleted' => 'Compte supprimé avec succès.', diff --git a/routes/api.php b/routes/api.php index a57376e..6e52671 100644 --- a/routes/api.php +++ b/routes/api.php @@ -35,6 +35,7 @@ Route::prefix('auth')->group(function (): void { Route::post('logout', [AuthController::class, 'logout']); Route::get('me', [AuthController::class, 'me']); Route::patch('me', [AuthController::class, 'update'])->middleware(['verified', 'throttle:content-write']); + Route::patch('me/password', [AuthController::class, 'updatePassword'])->middleware('throttle:account-action'); Route::delete('me', [AuthController::class, 'destroy'])->middleware('throttle:account-action'); }); diff --git a/tests/Feature/UpdatePasswordTest.php b/tests/Feature/UpdatePasswordTest.php new file mode 100644 index 0000000..fd1c4aa --- /dev/null +++ b/tests/Feature/UpdatePasswordTest.php @@ -0,0 +1,63 @@ +create([ + 'password' => Hash::make('OldPassword123!'), + ]); + $currentToken = $user->createToken('current-device'); + $otherToken = $user->createToken('other-device'); + + $this + ->withHeader('Authorization', 'Bearer '.$currentToken->plainTextToken) + ->patchJson('/api/auth/me/password', [ + 'currentPassword' => 'OldPassword123!', + 'password' => 'NewPassword123!', + 'passwordConfirmation' => 'NewPassword123!', + ]) + ->assertOk() + ->assertJsonPath('message', __('api.auth.password_updated')); + + expect(Hash::check('NewPassword123!', $user->fresh()->password))->toBeTrue(); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'id' => $currentToken->accessToken->getKey(), + ]); + $this->assertDatabaseMissing('personal_access_tokens', [ + 'id' => $otherToken->accessToken->getKey(), + ]); +}); + +it('rejects an invalid current password', function () { + $user = User::factory()->create([ + 'password' => Hash::make('OldPassword123!'), + ]); + $currentToken = $user->createToken('current-device'); + + $this + ->withHeader('Authorization', 'Bearer '.$currentToken->plainTextToken) + ->patchJson('/api/auth/me/password', [ + 'currentPassword' => 'wrong-password', + 'password' => 'NewPassword123!', + 'passwordConfirmation' => 'NewPassword123!', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['current_password']); + + expect(Hash::check('OldPassword123!', $user->fresh()->password))->toBeTrue(); +}); + +it('requires authentication to update the password', function () { + $this + ->patchJson('/api/auth/me/password', [ + 'currentPassword' => 'OldPassword123!', + 'password' => 'NewPassword123!', + 'passwordConfirmation' => 'NewPassword123!', + ]) + ->assertUnauthorized(); +});