Merge pull request #4874 from pixelfed/staging

Add forgot email feature
This commit is contained in:
daniel 2024-01-22 05:42:39 -07:00 committed by GitHub
commit dbf59367df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 470 additions and 39 deletions

View file

@ -14,6 +14,7 @@
- Added Mutual Followers API endpoint ([33dbbe46](https://github.com/pixelfed/pixelfed/commit/33dbbe46)) - 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 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 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 ### Federation
- Update Privacy Settings, add support for Mastodon `indexable` search flag ([fc24630e](https://github.com/pixelfed/pixelfed/commit/fc24630e)) - 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 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 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 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/)) - ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.9 (2023-08-21)](https://github.com/pixelfed/pixelfed/compare/v0.11.8...v0.11.9) ## [v0.11.9 (2023-08-21)](https://github.com/pixelfed/pixelfed/compare/v0.11.8...v0.11.9)

View file

@ -71,6 +71,7 @@ class LoginController extends Controller
$this->username() => 'required|email', $this->username() => 'required|email',
'password' => 'required|string|min:6', 'password' => 'required|string|min:6',
]; ];
$messages = [];
if( if(
config('captcha.enabled') || config('captcha.enabled') ||
@ -82,9 +83,9 @@ class LoginController extends Controller
) )
) { ) {
$rules['h-captcha-response'] = 'required|filled|captcha|min:5'; $rules['h-captcha-response'] = 'required|filled|captcha|min:5';
$messages['h-captcha-response.required'] = 'The captcha must be filled';
} }
$request->validate($rules, $messages);
$this->validate($request, $rules);
} }
/** /**

View file

@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\User;
use App\Models\UserEmailForgot;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
use App\Mail\UserEmailForgotReminder;
use Illuminate\Support\Facades\RateLimiter;
class UserEmailForgotController extends Controller
{
public function __construct()
{
$this->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();
});
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class UserEmailForgotReminder extends Mailable
{
use Queueable, SerializesModels;
public $user;
/**
* Create a new message instance.
*/
public function __construct($user)
{
$this->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<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UserEmailForgot extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'email_sent_at' => 'datetime',
];
}

View file

@ -5,5 +5,18 @@ return [
'verify_dns' => env('PF_SECURITY_URL_VERIFY_DNS', false), 'verify_dns' => env('PF_SECURITY_URL_VERIFY_DNS', false),
'trusted_domains' => env('PF_SECURITY_URL_TRUSTED_DOMAINS', 'pixelfed.social,pixelfed.art,mastodon.social'), '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),
]
]
] ]
]; ];

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_email_forgots', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,127 @@
@extends('layouts.blank')
@push('styles')
<link href="{{ mix('css/landing.css') }}" rel="stylesheet">
<link rel="preload" as="image" href="{{ url('/_landing/bg.jpg')}}" />
@endpush
@section('content')
<div class="page-wrapper">
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-xl-6 col-lg-5 col-md-7 col-12">
<div class="text-center">
<a href="/">
<img src="/img/pixelfed-icon-white.svg" height="60px">
</a>
<h1 class="pt-4 pb-1">Forgot Email</h1>
<p class="font-weight-light pb-2">Recover your account by sending an email to an associated username</p>
</div>
@if(session('status'))
<div class="alert alert-success">
<div class="d-flex align-items-center font-weight-bold" style="gap:1rem;">
<i class="far fa-check-circle fa-lg" style="opacity:70%"></i>
{{ session('status') }}
</div>
</div>
@endif
@if ($errors->any())
@foreach ($errors->all() as $error)
<div class="alert alert-danger bg-danger text-white border-danger">
<div class="d-flex align-items-center font-weight-bold" style="gap:1rem;">
<i class="far fa-exclamation-triangle fa-2x" style="opacity:70%"></i>
{{ $error }}
</div>
</div>
@endforeach
@endif
<div class="card bg-glass">
<div class="card-header bg-transparent p-3 text-center font-weight-bold" style="border-bottom:1px solid #ffffff20">{{ __('Recover Email') }}</div>
<div class="card-body">
<form id="passwordReset" method="POST" action="{{ route('email.forgot') }}">
@csrf
<div class="form-group row">
<div class="col-md-12">
<label class="font-weight-bold small text-muted">Username</label>
<input
id="username"
type="text"
class="form-control form-control-lg bg-glass text-white"
name="username"
maxlength="15"
placeholder="{{ __('Your username') }}" required>
@if ($errors->has('username') )
<span class="text-danger small mb-3">
<strong>{{ $errors->first('username') }}</strong>
</span>
@endif
</div>
</div>
@if(config('captcha.enabled'))
<label class="font-weight-bold small text-muted">Captcha</label>
<div class="d-flex flex-grow-1">
{!! Captcha::display(['data-theme' => 'dark']) !!}
</div>
@if ($errors->has('h-captcha-response'))
<div class="text-danger small mb-3">
<strong>{{ $errors->first('h-captcha-response') }}</strong>
</div>
@endif
@endif
<div class="form-group row pt-4 mb-0">
<div class="col-md-12">
<button type="button" id="sbtn" class="btn btn-primary btn-block rounded-pill font-weight-bold" onclick="event.preventDefault();handleSubmit()">
{{ __('Send Email Reminder') }}
</button>
</div>
</div>
</form>
</div>
</div>
<div class="mt-3 d-flex justify-content-between align-items-center">
<a class="btn btn-link text-white font-weight-bold text-decoration-none" href="{{ route('login') }}">
<i class="far fa-long-arrow-left fa-lg mr-1"></i> {{ __('Back to Login') }}
</a>
<a href="{{ route('password.request') }}" class="text-white font-weight-bold text-decoration-none">Forgot password?</a>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript">
function handleSubmit() {
let username = document.getElementById('username');
username.classList.add('disabled');
let btn = document.getElementById('sbtn');
btn.classList.add('disabled');
btn.setAttribute('disabled', 'disabled');
btn.innerHTML = '<div class="spinner-border spinner-border-sm" role="status"><span class="sr-only">Loading...</span></div>';
document.getElementById('passwordReset').submit()
}
</script>
@endpush
@push('styles')
<style>
.bg-glass:focus {
background: rgba(255, 255, 255, 0.05) !important;
box-shadow: none !important;
border-color: rgba(255, 255, 255, 0.3);
}
</style>
@endpush

View file

@ -11,11 +11,18 @@
</h4> </h4>
</div> </div>
@if ($errors->any())
@foreach ($errors->all() as $error)
<div class="alert alert-danger m-3">
<span class="font-weight-bold small"><i class="far fa-exclamation-triangle mr-2"></i> {{ $error }}</span>
</div>
@endforeach
@endif
<div class="card-body"> <div class="card-body">
<form method="POST" action="{{ route('login') }}"> <form method="POST" action="{{ route('login') }}">
@csrf @csrf
<div class="form-group row"> <div class="form-group row mb-0">
<div class="col-md-12"> <div class="col-md-12">
<label for="email" class="small font-weight-bold text-muted mb-0">Email Address</label> <label for="email" class="small font-weight-bold text-muted mb-0">Email Address</label>
@ -26,10 +33,16 @@
<strong>{{ $errors->first('email') }}</strong> <strong>{{ $errors->first('email') }}</strong>
</span> </span>
@endif @endif
<div class="help-text small text-right mb-0">
<a href="{{ route('email.forgot') }}" class="small text-muted font-weight-bold">
{{ __('Forgot Email') }}
</a>
</div>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row mb-0">
<div class="col-md-12"> <div class="col-md-12">
<label for="password" class="small font-weight-bold text-muted mb-0">Password</label> <label for="password" class="small font-weight-bold text-muted mb-0">Password</label>

View file

@ -9,7 +9,7 @@
<div class="page-wrapper"> <div class="page-wrapper">
<div class="container mt-4"> <div class="container mt-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-5"> <div class="col-xl-6 col-lg-5 col-md-7 col-12">
<div class="text-center"> <div class="text-center">
<a href="/"> <a href="/">
<img src="/img/pixelfed-icon-white.svg" height="60px"> <img src="/img/pixelfed-icon-white.svg" height="60px">
@ -19,9 +19,9 @@
</div> </div>
@if(session('status') || $errors->has('email')) @if(session('status') || $errors->has('email'))
<div class="alert alert-info small"> <div class="alert alert-info">
<div class="d-flex align-items-center font-weight-bold" style="gap:0.5rem;"> <div class="d-flex align-items-center font-weight-bold" style="gap:1rem;">
<i class="far fa-exclamation-triangle fa-lg" style="opacity:20%"></i> <i class="far fa-exclamation-triangle fa-2x" style="opacity:70%"></i>
{{ session('status') ?? $errors->first('email') }} {{ session('status') ?? $errors->first('email') }}
</div> </div>
@ -39,7 +39,13 @@
<div class="form-group row"> <div class="form-group row">
<div class="col-md-12"> <div class="col-md-12">
<label class="font-weight-bold small text-muted">Email</label> <label class="font-weight-bold small text-muted">Email</label>
<input id="email" type="email" class="form-control" name="email" placeholder="{{ __('E-Mail Address') }}" required> <input
id="email"
type="email"
class="form-control form-control-lg bg-glass text-white"
name="email"
placeholder="{{ __('E-Mail Address') }}"
required>
@if ($errors->has('email') && $errors->first('email') === 'The email must be a valid email address.') @if ($errors->has('email') && $errors->first('email') === 'The email must be a valid email address.')
<span class="text-danger small mb-3"> <span class="text-danger small mb-3">
<strong>{{ $errors->first('email') }}</strong> <strong>{{ $errors->first('email') }}</strong>
@ -76,7 +82,7 @@
<i class="far fa-long-arrow-left fa-lg mr-1"></i> {{ __('Back to Login') }} <i class="far fa-long-arrow-left fa-lg mr-1"></i> {{ __('Back to Login') }}
</a> </a>
<a href="#" class="text-white font-weight-bold text-decoration-none" onclick="event.preventDefault();forgotUsername()">Forgot email?</a> <a href="{{route('email.forgot')}}" class="text-white font-weight-bold text-decoration-none">Forgot email?</a>
</div> </div>
</div> </div>
</div> </div>
@ -86,29 +92,6 @@
@push('scripts') @push('scripts')
<script type="text/javascript"> <script type="text/javascript">
function forgotUsername() {
swal({
title: 'Forgot email?',
text: 'Contact the instance admins to assist you in recovering your account.',
icon: 'info',
buttons: {
contact: {
text: "Contact Admins",
value: "contact",
className: "bg-danger"
},
cancel: "Close",
},
})
.then((value) => {
switch(value) {
case 'contact':
window.location.href = '/site/contact';
break;
}
});
}
function handleSubmit() { function handleSubmit() {
let email = document.getElementById('email'); let email = document.getElementById('email');
email.classList.add('disabled'); email.classList.add('disabled');
@ -121,3 +104,13 @@
} }
</script> </script>
@endpush @endpush
@push('styles')
<style>
.bg-glass:focus {
background: rgba(255, 255, 255, 0.05) !important;
box-shadow: none !important;
border-color: rgba(255, 255, 255, 0.3);
}
</style>
@endpush

View file

@ -9,7 +9,7 @@
<div class="page-wrapper"> <div class="page-wrapper">
<div class="container mt-4"> <div class="container mt-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-5"> <div class="col-xl-6 col-lg-5 col-md-7 col-12">
<div class="text-center"> <div class="text-center">
<a href="/"> <a href="/">
<img src="/img/pixelfed-icon-white.svg" height="60px"> <img src="/img/pixelfed-icon-white.svg" height="60px">
@ -41,14 +41,14 @@
<label class="font-weight-bold small text-muted">Email</label> <label class="font-weight-bold small text-muted">Email</label>
<input <input
id="email" id="email"
type="email" type="text"
class="form-control {{ $errors->has('email') ? ' is-invalid' : '' }}" class="form-control form-control-lg bg-dark bg-glass text-white{{ $errors->has('email') ? ' is-invalid' : '' }}"
name="email" name="email"
value="{{ $email ?? old('email') }}" value="{{ $email ?? old('email') }}"
placeholder="{{ __('E-Mail Address') }}" placeholder="{{ __('E-Mail Address') }}"
required required
disabled disabled
style="opacity: 20%;"> style="opacity:.5">
@if ($errors->has('email')) @if ($errors->has('email'))
<span class="invalid-feedback"> <span class="invalid-feedback">
@ -67,7 +67,7 @@
<input <input
id="password" id="password"
type="password" type="password"
class="form-control{{ $errors->has('password') ? ' is-invalid' : '' }}" class="form-control form-control-lg bg-glass text-white{{ $errors->has('password') ? ' is-invalid' : '' }}"
name="password" name="password"
placeholder="{{ __('Password') }}" placeholder="{{ __('Password') }}"
minlength="{{config('pixelfed.min_password_length')}}" minlength="{{config('pixelfed.min_password_length')}}"
@ -93,7 +93,7 @@
<input <input
id="password-confirm" id="password-confirm"
type="password" type="password"
class="form-control{{ $errors->has('password_confirmation') ? ' is-invalid' : '' }}" class="form-control form-control-lg bg-glass text-white{{ $errors->has('password_confirmation') ? ' is-invalid' : '' }}"
name="password_confirmation" name="password_confirmation"
placeholder="{{ __('Confirm Password') }}" placeholder="{{ __('Confirm Password') }}"
minlength="{{config('pixelfed.min_password_length')}}" minlength="{{config('pixelfed.min_password_length')}}"
@ -152,3 +152,13 @@
} }
</script> </script>
@endpush @endpush
@push('styles')
<style>
.bg-glass:focus {
background: rgba(255, 255, 255, 0.05) !important;
box-shadow: none !important;
border-color: rgba(255, 255, 255, 0.3);
}
</style>
@endpush

View file

@ -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.
<x-mail::button :url="url('/login?email=' . $user->email)" color="success">
Login to my <strong>{{'@' . $user->username}}</strong> account
</x-mail::button>
----
<br>
The email address linked to your username is:
<x-mail::panel>
<p>
<strong>{{$user->email}}</strong>
</p>
</x-mail::panel>
You can use this email address to log in to your account.
<small>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')}}).</small>
Thank you for being a part of our community!
Best regards,<br>
<a href="{{ config('app.url') }}"><strong>{{ config('pixelfed.domain.app') }}</strong></a>
<br>
<hr>
<p style="font-size:10pt;">This is an automated message, please be aware that replies to this email cannot be monitored or responded to.</p>
@endcomponent

View file

@ -203,6 +203,9 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::get('auth/pci/{id}/{code}', 'ParentalControlsController@inviteRegister'); Route::get('auth/pci/{id}/{code}', 'ParentalControlsController@inviteRegister');
Route::post('auth/pci/{id}/{code}', 'ParentalControlsController@inviteRegisterStore'); 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::get('discover', 'DiscoverController@home')->name('discover');
Route::group(['prefix' => 'api'], function () { Route::group(['prefix' => 'api'], function () {