diff --git a/app/Helpers.php b/app/Helpers.php index a9353610..bc5fc4ab 100644 --- a/app/Helpers.php +++ b/app/Helpers.php @@ -86,3 +86,12 @@ function gravatar(string $email, int $size = 192): string { return sprintf("https://www.gravatar.com/avatar/%s?s=$size&d=robohash", md5($email)); } + +/** + * A quick check to determine if a mailer is configured. + * This is not bulletproof but should work in most cases. + */ +function mailer_configured(): bool +{ + return config('mail.default') && !in_array(config('mail.default'), ['log', 'array'], true); +} diff --git a/app/Http/Controllers/API/ForgotPasswordController.php b/app/Http/Controllers/API/ForgotPasswordController.php new file mode 100644 index 00000000..9cfb85a9 --- /dev/null +++ b/app/Http/Controllers/API/ForgotPasswordController.php @@ -0,0 +1,20 @@ +trySendResetPasswordLink($request->email) + ? response()->noContent() + : response('', Response::HTTP_NOT_FOUND); + } +} diff --git a/app/Http/Controllers/API/ProfileController.php b/app/Http/Controllers/API/ProfileController.php index 654f5272..1cdde1e5 100644 --- a/app/Http/Controllers/API/ProfileController.php +++ b/app/Http/Controllers/API/ProfileController.php @@ -9,6 +9,7 @@ use App\Models\User; use App\Services\TokenManager; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Hashing\Hasher; +use Illuminate\Http\Response; use Illuminate\Validation\ValidationException; class ProfileController extends Controller @@ -28,9 +29,7 @@ class ProfileController extends Controller public function update(ProfileUpdateRequest $request) { - if (config('koel.misc.demo')) { - return response()->noContent(); - } + static::disableInDemo(Response::HTTP_NO_CONTENT); throw_unless( $this->hash->check($request->current_password, $this->user->password), diff --git a/app/Http/Controllers/API/ResetPasswordController.php b/app/Http/Controllers/API/ResetPasswordController.php new file mode 100644 index 00000000..834e9d36 --- /dev/null +++ b/app/Http/Controllers/API/ResetPasswordController.php @@ -0,0 +1,20 @@ +tryResetPasswordUsingBroker($request->email, $request->password, $request->token) + ? response()->noContent() + : response('', Response::HTTP_UNPROCESSABLE_ENTITY); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 345847e2..98733dd6 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Validation\ValidatesRequests; +use Illuminate\Http\Response; use Illuminate\Routing\Controller as BaseController; abstract class Controller extends BaseController @@ -12,4 +13,9 @@ abstract class Controller extends BaseController use AuthorizesRequests; use DispatchesJobs; use ValidatesRequests; + + protected static function disableInDemo(int $code = Response::HTTP_FORBIDDEN): void + { + abort_if(config('koel.misc.demo'), $code); + } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index e06fd441..355bf514 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -8,6 +8,7 @@ use App\Http\Middleware\ForceHttps; use App\Http\Middleware\ObjectStorageAuthenticate; use App\Http\Middleware\ThrottleRequests; use App\Http\Middleware\TrimStrings; +use App\Http\Middleware\TrustHosts; use Illuminate\Auth\Middleware\Authorize; use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; @@ -26,6 +27,7 @@ class Kernel extends HttpKernel ValidatePostSize::class, TrimStrings::class, ForceHttps::class, + TrustHosts::class, ]; /** diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php new file mode 100644 index 00000000..85321dba --- /dev/null +++ b/app/Http/Middleware/TrustHosts.php @@ -0,0 +1,18 @@ + + */ + public function hosts(): array + { + return [ + $this->allSubdomainsOfApplicationUrl(), + ]; + } +} diff --git a/app/Http/Requests/API/ForgotPasswordRequest.php b/app/Http/Requests/API/ForgotPasswordRequest.php new file mode 100644 index 00000000..74877061 --- /dev/null +++ b/app/Http/Requests/API/ForgotPasswordRequest.php @@ -0,0 +1,17 @@ + */ + public function rules(): array + { + return [ + 'email' => 'required|email', + ]; + } +} diff --git a/app/Http/Requests/API/ResetPasswordRequest.php b/app/Http/Requests/API/ResetPasswordRequest.php new file mode 100644 index 00000000..9afce849 --- /dev/null +++ b/app/Http/Requests/API/ResetPasswordRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'token' => 'required', + 'email' => 'required|email', + 'password' => ['sometimes', Password::defaults()], + ]; + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 46411a12..6f063777 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use App\Models\User; use App\Services\TokenManager; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -25,6 +26,12 @@ class AuthServiceProvider extends ServiceProvider }); $this->setPasswordDefaultRules(); + + ResetPassword::createUrlUsing(static function (User $user, string $token): string { + $payload = base64_encode($user->getEmailForPasswordReset() . "|$token"); + + return url("/#/reset-password/$payload"); + }); } private function setPasswordDefaultRules(): void diff --git a/app/Services/AuthenticationService.php b/app/Services/AuthenticationService.php index fd106009..3c5d154e 100644 --- a/app/Services/AuthenticationService.php +++ b/app/Services/AuthenticationService.php @@ -6,14 +6,18 @@ use App\Exceptions\InvalidCredentialsException; use App\Models\User; use App\Repositories\UserRepository; use App\Values\CompositeToken; +use Illuminate\Auth\Events\PasswordReset; +use Illuminate\Auth\Passwords\PasswordBroker; use Illuminate\Hashing\HashManager; +use Illuminate\Support\Facades\Password; class AuthenticationService { public function __construct( private UserRepository $userRepository, private TokenManager $tokenManager, - private HashManager $hash + private HashManager $hash, + private PasswordBroker $passwordBroker ) { } @@ -38,4 +42,27 @@ class AuthenticationService { $this->tokenManager->deleteCompositionToken($token); } + + public function trySendResetPasswordLink(string $email): bool + { + return $this->passwordBroker->sendResetLink(['email' => $email]) === Password::RESET_LINK_SENT; + } + + public function tryResetPasswordUsingBroker(string $email, string $password, string $token): bool + { + $credentials = [ + 'email' => $email, + 'password' => $password, + 'password_confirmation' => $password, + 'token' => $token, + ]; + + $status = $this->passwordBroker->reset($credentials, function (User $user, string $password): void { + $user->password = $this->hash->make($password); + $user->save(); + event(new PasswordReset($user)); + }); + + return $status === Password::PASSWORD_RESET; + } } diff --git a/config/mail.php b/config/mail.php index 0e457422..534395a3 100644 --- a/config/mail.php +++ b/config/mail.php @@ -1,42 +1,85 @@ env('MAIL_DRIVER', 'smtp'), + + 'default' => env('MAIL_MAILER', 'smtp'), + /* |-------------------------------------------------------------------------- - | SMTP Host Address + | Mailer Configurations |-------------------------------------------------------------------------- | - | Here you may provide the host address of the SMTP server used by your - | applications. A default option is provided that is compatible with - | the Mailgun mail service which will provide reliable deliveries. + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers to be used while + | sending an e-mail. You will specify which one you are using for your + | mailers below. You are free to add additional mailers as required. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", + | "postmark", "log", "array", "failover" | */ - 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), - /* - |-------------------------------------------------------------------------- - | SMTP Host Port - |-------------------------------------------------------------------------- - | - | This is the SMTP port used by your application to deliver e-mails to - | users of the application. Like the host we have set this value to - | stay compatible with the Mailgun e-mail application by default. - | - */ - 'port' => env('MAIL_PORT', 587), + + 'mailers' => [ + 'smtp' => [ + 'transport' => 'smtp', + 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), + 'port' => env('MAIL_PORT', 587), + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN'), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'mailgun' => [ + 'transport' => 'mailgun', + ], + + 'postmark' => [ + 'transport' => 'postmark', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + ], + ], + /* |-------------------------------------------------------------------------- | Global "From" Address @@ -47,44 +90,12 @@ return [ | used globally for all e-mails that are sent by your application. | */ + 'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', 'noreply@koel.local'), - 'name' => env('MAIL_FROM_NAME', 'Koel'), + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), ], - /* - |-------------------------------------------------------------------------- - | E-Mail Encryption Protocol - |-------------------------------------------------------------------------- - | - | Here you may specify the encryption protocol that should be used when - | the application send e-mail messages. A sensible default using the - | transport layer security protocol should provide great security. - | - */ - 'encryption' => env('MAIL_ENCRYPTION', 'tls'), - /* - |-------------------------------------------------------------------------- - | SMTP Server Username - |-------------------------------------------------------------------------- - | - | If your SMTP server requires a username for authentication, you should - | set it here. This will get used to authenticate with your server on - | connection. You may also set the "password" value below this one. - | - */ - 'username' => env('MAIL_USERNAME'), - 'password' => env('MAIL_PASSWORD'), - /* - |-------------------------------------------------------------------------- - | Sendmail System Path - |-------------------------------------------------------------------------- - | - | When using the "sendmail" driver to send e-mails, we will need to know - | the path to where Sendmail lives on this server. A default path has - | been provided here, which will work well on most of your systems. - | - */ - 'sendmail' => '/usr/sbin/sendmail -bs', + /* |-------------------------------------------------------------------------- | Markdown Mail Settings @@ -95,10 +106,13 @@ return [ | of the emails. Or, you may simply stick with the Laravel defaults! | */ + 'markdown' => [ 'theme' => 'default', + 'paths' => [ resource_path('views/vendor/mail'), ], ], + ]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 442ad6e1..aace5c10 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -48,7 +48,7 @@ - + diff --git a/resources/assets/js/App.vue b/resources/assets/js/App.vue index 01df259d..8fbd10bd 100644 --- a/resources/assets/js/App.vue +++ b/resources/assets/js/App.vue @@ -22,6 +22,7 @@ + + + diff --git a/resources/assets/js/components/auth/LoginForm.spec.ts b/resources/assets/js/components/auth/LoginForm.spec.ts index 70181814..8c778e61 100644 --- a/resources/assets/js/components/auth/LoginForm.spec.ts +++ b/resources/assets/js/components/auth/LoginForm.spec.ts @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/vue' +import { screen, waitFor } from '@testing-library/vue' import { expect, it, Mock } from 'vitest' import UnitTestCase from '@/__tests__/UnitTestCase' import { authService } from '@/services' @@ -32,5 +32,20 @@ new class extends UnitTestCase { expect(emitted().loggedin).toBeFalsy() expect(screen.getByTestId('login-form').classList.contains('error')).toBe(true) }) + + it('shows forgot password form', async () => { + this.render(LoginFrom) + await this.user.click(screen.getByText('Forgot password?')) + + await waitFor(() => screen.getByTestId('forgot-password-form')) + }) + + it('does not show forgot password form if mailer is not configure', async () => { + window.MAILER_CONFIGURED = false + this.render(LoginFrom) + + expect(screen.queryByText('Forgot password?')).toBeNull() + window.MAILER_CONFIGURED = true + }) } } diff --git a/resources/assets/js/components/auth/LoginForm.vue b/resources/assets/js/components/auth/LoginForm.vue index dc819c9c..7a7916a4 100644 --- a/resources/assets/js/components/auth/LoginForm.vue +++ b/resources/assets/js/components/auth/LoginForm.vue @@ -1,33 +1,55 @@ + + diff --git a/resources/assets/js/components/auth/__snapshots__/LoginForm.spec.ts.snap b/resources/assets/js/components/auth/__snapshots__/LoginForm.spec.ts.snap index bbcf68dd..7669e285 100644 --- a/resources/assets/js/components/auth/__snapshots__/LoginForm.spec.ts.snap +++ b/resources/assets/js/components/auth/__snapshots__/LoginForm.spec.ts.snap @@ -4,7 +4,8 @@ exports[`renders 1`] = ` `; diff --git a/resources/assets/js/components/invitation/AcceptInvitation.vue b/resources/assets/js/components/invitation/AcceptInvitation.vue index e96c18c8..6a42cdd3 100644 --- a/resources/assets/js/components/invitation/AcceptInvitation.vue +++ b/resources/assets/js/components/invitation/AcceptInvitation.vue @@ -15,20 +15,27 @@
- Accept & Log In + Accept & Log In
@@ -37,7 +44,7 @@ diff --git a/routes/api.base.php b/routes/api.base.php index d4a4d01f..ece62d10 100644 --- a/routes/api.base.php +++ b/routes/api.base.php @@ -20,6 +20,7 @@ use App\Http\Controllers\API\FetchOverviewController; use App\Http\Controllers\API\FetchRandomSongsInGenreController; use App\Http\Controllers\API\FetchRecentlyPlayedSongController; use App\Http\Controllers\API\FetchSongsForQueueController; +use App\Http\Controllers\API\ForgotPasswordController; use App\Http\Controllers\API\GenreController; use App\Http\Controllers\API\GenreSongController; use App\Http\Controllers\API\LambdaSongController as S3SongController; @@ -37,6 +38,7 @@ use App\Http\Controllers\API\ProfileController; use App\Http\Controllers\API\PublicizeSongsController; use App\Http\Controllers\API\QueueStateController; use App\Http\Controllers\API\RegisterPlayController; +use App\Http\Controllers\API\ResetPasswordController; use App\Http\Controllers\API\ScrobbleController; use App\Http\Controllers\API\SearchYouTubeController; use App\Http\Controllers\API\SetLastfmSessionKeyController; @@ -59,10 +61,13 @@ use Illuminate\Support\Facades\Route; use Pusher\Pusher; Route::prefix('api')->middleware('api')->group(static function (): void { + Route::get('ping', static fn () => null); + Route::post('me', [AuthController::class, 'login'])->name('auth.login'); Route::delete('me', [AuthController::class, 'logout']); - Route::get('ping', static fn () => null); + Route::post('forgot-password', ForgotPasswordController::class); + Route::post('reset-password', ResetPasswordController::class); Route::get('invitations', [UserInvitationController::class, 'get']); Route::post('invitations/accept', [UserInvitationController::class, 'accept']); diff --git a/tests/Feature/ForgotPasswordTest.php b/tests/Feature/ForgotPasswordTest.php new file mode 100644 index 00000000..f98bcb6b --- /dev/null +++ b/tests/Feature/ForgotPasswordTest.php @@ -0,0 +1,67 @@ +mock(AuthenticationService::class) + ->shouldReceive('trySendResetPasswordLink') + ->with('foo@bar.com') + ->andReturnTrue(); + + $this->post('/api/forgot-password', ['email' => 'foo@bar.com']) + ->assertNoContent(); + } + + public function testSendResetPasswordRequestFailed(): void + { + $this->mock(AuthenticationService::class) + ->shouldReceive('trySendResetPasswordLink') + ->with('foo@bar.com') + ->andReturnFalse(); + + $this->post('/api/forgot-password', ['email' => 'foo@bar.com']) + ->assertNotFound(); + } + + public function testResetPassword(): void + { + Event::fake(); + $user = create_user(); + + $this->post('/api/reset-password', [ + 'email' => $user->email, + 'password' => 'new-password', + 'token' => Password::createToken($user), + ])->assertNoContent(); + + self::assertTrue(Hash::check('new-password', $user->refresh()->password)); + Event::assertDispatched(PasswordReset::class); + } + + public function testResetPasswordFailed(): void + { + Event::fake(); + $user = create_user(['password' => Hash::make('old-password')]); + + $this->post('/api/reset-password', [ + 'email' => $user->email, + 'password' => 'new-password', + 'token' => 'invalid-token', + ])->assertUnprocessable(); + + self::assertTrue(Hash::check('old-password', $user->refresh()->password)); + Event::assertNotDispatched(PasswordReset::class); + } +} diff --git a/tests/Integration/Services/AuthenticationServiceTest.php b/tests/Integration/Services/AuthenticationServiceTest.php new file mode 100644 index 00000000..629435e7 --- /dev/null +++ b/tests/Integration/Services/AuthenticationServiceTest.php @@ -0,0 +1,48 @@ +service = app(AuthenticationService::class); + } + + public function testTryResetPasswordUsingBroker(): void + { + Event::fake(); + $user = create_user(); + + self::assertTrue( + $this->service->tryResetPasswordUsingBroker($user->email, 'new-password', Password::createToken($user)) + ); + + self::assertTrue(Hash::check('new-password', $user->fresh()->password)); + + Event::assertDispatched(PasswordReset::class); + } + + public function testTryResetPasswordUsingBrokerWithInvalidToken(): void + { + Event::fake(); + $user = create_user(['password' => Hash::make('old-password')]); + + self::assertFalse($this->service->tryResetPasswordUsingBroker($user->email, 'new-password', 'invalid-token')); + self::assertTrue(Hash::check('old-password', $user->fresh()->password)); + Event::assertNotDispatched(PasswordReset::class); + } +} diff --git a/tests/Unit/Services/AuthenticationServiceTest.php b/tests/Unit/Services/AuthenticationServiceTest.php new file mode 100644 index 00000000..bbadbc03 --- /dev/null +++ b/tests/Unit/Services/AuthenticationServiceTest.php @@ -0,0 +1,48 @@ +userRepository = $this->mock(UserRepository::class); + $this->tokenManager = $this->mock(TokenManager::class); + $this->hash = $this->mock(HashManager::class); + $this->passwordBroker = $this->mock(PasswordBroker::class); + + $this->service = new AuthenticationService( + $this->userRepository, + $this->tokenManager, + $this->hash, + $this->passwordBroker + ); + } + + public function testTrySendResetPasswordLink(): void + { + $this->passwordBroker + ->shouldReceive('sendResetLink') + ->with(['email' => 'foo@bar.com']) + ->andReturn(Password::RESET_LINK_SENT); + + $this->assertTrue($this->service->trySendResetPasswordLink('foo@bar.com')); + } +}