diff --git a/CHANGELOG.md b/CHANGELOG.md index dcedd4896..d251b3df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Added Mutual Followers API endpoint ([33dbbe46](https://github.com/pixelfed/pixelfed/commit/33dbbe46)) - Added User Domain Blocks ([#4834](https://github.com/pixelfed/pixelfed/pull/4834)) ([fa0380ac](https://github.com/pixelfed/pixelfed/commit/fa0380ac)) - Added Parental Controls ([#4862](https://github.com/pixelfed/pixelfed/pull/4862)) ([c91f1c59](https://github.com/pixelfed/pixelfed/commit/c91f1c59)) +- Added Forgot Email Feature ([67c650b1](https://github.com/pixelfed/pixelfed/commit/67c650b1)) ### Federation - Update Privacy Settings, add support for Mastodon `indexable` search flag ([fc24630e](https://github.com/pixelfed/pixelfed/commit/fc24630e)) @@ -87,6 +88,7 @@ - Update AP ProfileTransformer, add published attribute ([adfaa2b1](https://github.com/pixelfed/pixelfed/commit/adfaa2b1)) - Update meta tags, improve descriptions and seo/og tags ([fd44c80c](https://github.com/pixelfed/pixelfed/commit/fd44c80c)) - Update login view, add email prefill logic ([d76f0168](https://github.com/pixelfed/pixelfed/commit/d76f0168)) +- Update LoginController, fix captcha validation error message ([0325e171](https://github.com/pixelfed/pixelfed/commit/0325e171)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.11.9 (2023-08-21)](https://github.com/pixelfed/pixelfed/compare/v0.11.8...v0.11.9) diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 3861d3272..627a879cc 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -71,6 +71,7 @@ class LoginController extends Controller $this->username() => 'required|email', 'password' => 'required|string|min:6', ]; + $messages = []; if( config('captcha.enabled') || @@ -82,9 +83,9 @@ class LoginController extends Controller ) ) { $rules['h-captcha-response'] = 'required|filled|captcha|min:5'; + $messages['h-captcha-response.required'] = 'The captcha must be filled'; } - - $this->validate($request, $rules); + $request->validate($rules, $messages); } /** diff --git a/app/Http/Controllers/UserEmailForgotController.php b/app/Http/Controllers/UserEmailForgotController.php new file mode 100644 index 000000000..c97add2a7 --- /dev/null +++ b/app/Http/Controllers/UserEmailForgotController.php @@ -0,0 +1,132 @@ +middleware('guest'); + abort_unless(config('security.forgot-email.enabled'), 404); + } + + public function index(Request $request) + { + abort_if($request->user(), 404); + return view('auth.email.forgot'); + } + + public function store(Request $request) + { + $rules = [ + 'username' => 'required|min:2|max:15|exists:users' + ]; + + $messages = [ + 'username.exists' => 'This username is no longer active or does not exist!' + ]; + + if(config('captcha.enabled') || config('captcha.active.register')) { + $rules['h-captcha-response'] = 'required|captcha'; + $messages['h-captcha-response.required'] = 'You need to complete the captcha!'; + } + + $randomDelay = random_int(500000, 2000000); + usleep($randomDelay); + + $this->validate($request, $rules, $messages); + $check = self::checkLimits(); + + if(!$check) { + return redirect()->back()->withErrors([ + 'username' => 'Please try again later, we\'ve reached our quota and cannot process any more requests at this time.' + ]); + } + + $user = User::whereUsername($request->input('username')) + ->whereNotNull('email_verified_at') + ->whereNull('status') + ->whereIsAdmin(false) + ->first(); + + if(!$user) { + return redirect()->back()->withErrors([ + 'username' => 'Invalid username or account. It may not exist, or does not have a verified email, is an admin account or is disabled.' + ]); + } + + $exists = UserEmailForgot::whereUserId($user->id) + ->where('email_sent_at', '>', now()->subHours(24)) + ->count(); + + if($exists) { + return redirect()->back()->withErrors([ + 'username' => 'An email reminder was recently sent to this account, please try again after 24 hours!' + ]); + } + + return $this->storeHandle($request, $user); + } + + protected function storeHandle($request, $user) + { + UserEmailForgot::create([ + 'user_id' => $user->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'referrer' => $request->headers->get('referer'), + 'email_sent_at' => now() + ]); + + Mail::to($user->email)->send(new UserEmailForgotReminder($user)); + self::getLimits(true); + return redirect()->back()->with(['status' => 'Successfully sent an email reminder!']); + } + + public static function checkLimits() + { + $limits = self::getLimits(); + + if( + $limits['current']['hourly'] >= $limits['max']['hourly'] || + $limits['current']['daily'] >= $limits['max']['daily'] || + $limits['current']['weekly'] >= $limits['max']['weekly'] || + $limits['current']['monthly'] >= $limits['max']['monthly'] + ) { + return false; + } + + return true; + } + + public static function getLimits($forget = false) + { + return [ + 'max' => config('security.forgot-email.limits.max'), + 'current' => [ + 'hourly' => self::activeCount(60, $forget), + 'daily' => self::activeCount(1440, $forget), + 'weekly' => self::activeCount(10080, $forget), + 'monthly' => self::activeCount(43800, $forget) + ] + ]; + } + + public static function activeCount($mins, $forget = false) + { + if($forget) { + Cache::forget('pf:auth:forgot-email:active-count:dur-' . $mins); + } + return Cache::remember('pf:auth:forgot-email:active-count:dur-' . $mins, 14200, function() use($mins) { + return UserEmailForgot::where('email_sent_at', '>', now()->subMinutes($mins))->count(); + }); + } +} diff --git a/app/Mail/UserEmailForgotReminder.php b/app/Mail/UserEmailForgotReminder.php new file mode 100644 index 000000000..f7e5dbc10 --- /dev/null +++ b/app/Mail/UserEmailForgotReminder.php @@ -0,0 +1,55 @@ +user = $user; + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + return new Envelope( + subject: '[' . config('pixelfed.domain.app') . '] Pixelfed Account Email Reminder', + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'emails.forgot-email.message', + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/UserEmailForgot.php b/app/Models/UserEmailForgot.php new file mode 100644 index 000000000..9e549aff4 --- /dev/null +++ b/app/Models/UserEmailForgot.php @@ -0,0 +1,17 @@ + 'datetime', + ]; +} diff --git a/config/security.php b/config/security.php index a8f92360d..929c05214 100644 --- a/config/security.php +++ b/config/security.php @@ -5,5 +5,18 @@ return [ 'verify_dns' => env('PF_SECURITY_URL_VERIFY_DNS', false), 'trusted_domains' => env('PF_SECURITY_URL_TRUSTED_DOMAINS', 'pixelfed.social,pixelfed.art,mastodon.social'), + ], + + 'forgot-email' => [ + 'enabled' => env('PF_AUTH_ALLOW_EMAIL_FORGOT', true), + + 'limits' => [ + 'max' => [ + 'hourly' => env('PF_AUTH_FORGOT_EMAIL_MAX_HOURLY', 50), + 'daily' => env('PF_AUTH_FORGOT_EMAIL_MAX_DAILY', 100), + 'weekly' => env('PF_AUTH_FORGOT_EMAIL_MAX_WEEKLY', 200), + 'monthly' => env('PF_AUTH_FORGOT_EMAIL_MAX_MONTHLY', 500), + ] + ] ] ]; diff --git a/database/migrations/2024_01_22_090048_create_user_email_forgots_table.php b/database/migrations/2024_01_22_090048_create_user_email_forgots_table.php new file mode 100644 index 000000000..845b63934 --- /dev/null +++ b/database/migrations/2024_01_22_090048_create_user_email_forgots_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedInteger('user_id')->index(); + $table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->string('referrer')->nullable(); + $table->timestamp('email_sent_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_email_forgots'); + } +}; diff --git a/resources/views/auth/email/forgot.blade.php b/resources/views/auth/email/forgot.blade.php new file mode 100644 index 000000000..73d01811c --- /dev/null +++ b/resources/views/auth/email/forgot.blade.php @@ -0,0 +1,127 @@ +@extends('layouts.blank') + +@push('styles') + + +@endpush + +@section('content') +
+
+
+
+
+ + + +

Forgot Email

+

Recover your account by sending an email to an associated username

+
+ + @if(session('status')) +
+
+ + + {{ session('status') }} +
+
+ @endif + + @if ($errors->any()) + @foreach ($errors->all() as $error) +
+
+ + {{ $error }} +
+
+ @endforeach + @endif + +
+
{{ __('Recover Email') }}
+ +
+ +
+ @csrf + +
+
+ + + @if ($errors->has('username') ) + + {{ $errors->first('username') }} + + @endif +
+
+ + @if(config('captcha.enabled')) + +
+ {!! Captcha::display(['data-theme' => 'dark']) !!} +
+ @if ($errors->has('h-captcha-response')) +
+ {{ $errors->first('h-captcha-response') }} +
+ @endif + @endif + +
+
+ +
+
+
+
+
+ + +
+
+
+
+@endsection + +@push('scripts') + +@endpush + +@push('styles') + +@endpush diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index af6c506e1..dadd08a4f 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -11,11 +11,18 @@ + @if ($errors->any()) + @foreach ($errors->all() as $error) +
+ {{ $error }} +
+ @endforeach + @endif
@csrf -
+
@@ -26,10 +33,16 @@ {{ $errors->first('email') }} @endif + +
-
+
diff --git a/resources/views/auth/passwords/email.blade.php b/resources/views/auth/passwords/email.blade.php index d144e142a..4f2825e29 100644 --- a/resources/views/auth/passwords/email.blade.php +++ b/resources/views/auth/passwords/email.blade.php @@ -9,7 +9,7 @@
-
+
@if(session('status') || $errors->has('email')) -
-
- + @@ -86,29 +92,6 @@ @push('scripts') @endpush + +@push('styles') + +@endpush diff --git a/resources/views/auth/passwords/reset.blade.php b/resources/views/auth/passwords/reset.blade.php index efe59ac95..1a740fa7d 100644 --- a/resources/views/auth/passwords/reset.blade.php +++ b/resources/views/auth/passwords/reset.blade.php @@ -9,7 +9,7 @@
-
+
@@ -41,14 +41,14 @@ + style="opacity:.5"> @if ($errors->has('email')) @@ -67,7 +67,7 @@ @endpush + +@push('styles') + +@endpush diff --git a/resources/views/emails/forgot-email/message.blade.php b/resources/views/emails/forgot-email/message.blade.php new file mode 100644 index 000000000..af94df3df --- /dev/null +++ b/resources/views/emails/forgot-email/message.blade.php @@ -0,0 +1,33 @@ +@component('mail::message') +Hello, + +You recently requested to know the email address associated with your username [**{{'@' . $user->username}}**]({{$user->url()}}) on [**{{config('pixelfed.domain.app')}}**]({{config('app.url')}}). + +We're here to assist! Simply tap on the Login button below. + + +Login to my {{'@' . $user->username}} account + + +---- +
+ +The email address linked to your username is: + +

+{{$user->email}} +

+
+ +You can use this email address to log in to your account. + +If needed, you can [reset your password]({{ route('password.request')}}). For security reasons, we recommend keeping your account information, including your email address, updated and secure. If you did not make this request or if you have any other questions or concerns, please feel free to [contact our support team]({{route('site.contact')}}). + +Thank you for being a part of our community! + +Best regards,
+
{{ config('pixelfed.domain.app') }} +
+
+

This is an automated message, please be aware that replies to this email cannot be monitored or responded to.

+@endcomponent diff --git a/routes/web.php b/routes/web.php index e71e6fd9e..6c765ba56 100644 --- a/routes/web.php +++ b/routes/web.php @@ -203,6 +203,9 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('auth/pci/{id}/{code}', 'ParentalControlsController@inviteRegister'); Route::post('auth/pci/{id}/{code}', 'ParentalControlsController@inviteRegisterStore'); + Route::get('auth/forgot/email', 'UserEmailForgotController@index')->name('email.forgot'); + Route::post('auth/forgot/email', 'UserEmailForgotController@store')->middleware('throttle:10,900,forgotEmail'); + Route::get('discover', 'DiscoverController@home')->name('discover'); Route::group(['prefix' => 'api'], function () {