Add forgot email feature

This commit is contained in:
Daniel Supernault 2024-01-22 05:26:01 -07:00
parent 0325e17115
commit 67c650b195
No known key found for this signature in database
GPG Key ID: 23740873EE6F76A1
10 changed files with 433 additions and 37 deletions

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),
'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,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>
</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">
<form method="POST" action="{{ route('login') }}">
@csrf
<div class="form-group row">
<div class="form-group row mb-0">
<div class="col-md-12">
<label for="email" class="small font-weight-bold text-muted mb-0">Email Address</label>
@ -26,10 +33,16 @@
<strong>{{ $errors->first('email') }}</strong>
</span>
@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 class="form-group row">
<div class="form-group row mb-0">
<div class="col-md-12">
<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="container mt-4">
<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">
<a href="/">
<img src="/img/pixelfed-icon-white.svg" height="60px">
@ -19,9 +19,9 @@
</div>
@if(session('status') || $errors->has('email'))
<div class="alert alert-info small">
<div class="d-flex align-items-center font-weight-bold" style="gap:0.5rem;">
<i class="far fa-exclamation-triangle fa-lg" style="opacity:20%"></i>
<div class="alert alert-info">
<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>
{{ session('status') ?? $errors->first('email') }}
</div>
@ -39,7 +39,13 @@
<div class="form-group row">
<div class="col-md-12">
<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.')
<span class="text-danger small mb-3">
<strong>{{ $errors->first('email') }}</strong>
@ -76,7 +82,7 @@
<i class="far fa-long-arrow-left fa-lg mr-1"></i> {{ __('Back to Login') }}
</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>
@ -86,29 +92,6 @@
@push('scripts')
<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() {
let email = document.getElementById('email');
email.classList.add('disabled');
@ -121,3 +104,13 @@
}
</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

@ -9,7 +9,7 @@
<div class="page-wrapper">
<div class="container mt-4">
<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">
<a href="/">
<img src="/img/pixelfed-icon-white.svg" height="60px">
@ -41,14 +41,14 @@
<label class="font-weight-bold small text-muted">Email</label>
<input
id="email"
type="email"
class="form-control {{ $errors->has('email') ? ' is-invalid' : '' }}"
type="text"
class="form-control form-control-lg bg-dark bg-glass text-white{{ $errors->has('email') ? ' is-invalid' : '' }}"
name="email"
value="{{ $email ?? old('email') }}"
placeholder="{{ __('E-Mail Address') }}"
required
disabled
style="opacity: 20%;">
style="opacity:.5">
@if ($errors->has('email'))
<span class="invalid-feedback">
@ -67,7 +67,7 @@
<input
id="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"
placeholder="{{ __('Password') }}"
minlength="{{config('pixelfed.min_password_length')}}"
@ -93,7 +93,7 @@
<input
id="password-confirm"
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"
placeholder="{{ __('Confirm Password') }}"
minlength="{{config('pixelfed.min_password_length')}}"
@ -152,3 +152,13 @@
}
</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

@ -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::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 () {