updated tokens and emails

This commit is contained in:
2024-05-06 20:13:31 +10:00
parent 39ea570f3a
commit 742da4bf17
35 changed files with 627 additions and 340 deletions

View File

@@ -4,11 +4,9 @@ namespace App\Http\Controllers;
use App\Helpers;
use App\Jobs\SendEmail;
use App\Mail\EmailUpdateLink;
use App\Mail\RegisterLink;
use App\Mail\UserEmailUpdateRequest;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
@@ -49,7 +47,7 @@ class AccountController extends Controller
$validator = Validator::make($request->all(), [
'firstname' => 'required',
'surname' => 'required',
'email' => ['required', 'email', Rule::unique('users')->ignore($user->id)],
'email' => ['required', 'email', 'unique:users,email,' . $user->id],
'phone' => 'required',
'home_address' => 'required_with:home_city,home_postcode,home_country,home_state',
@@ -92,20 +90,18 @@ class AccountController extends Controller
$newEmail = $userData['email'];
unset($userData['email']);
if ($user->email !== $newEmail) {
if(User::where('email', $request->get('email'))->exists()) {
$validator->errors()->add('email', __('validation.custom_messages.email_exists'));
return redirect()->back()->withErrors($validator)->withInput();
}
if (strtolower($user->email) !== strtolower($newEmail)) {
$user->tokens()->where('type', 'email-update')->delete();
$token = Str::random(60);
$user->emailUpdate()->delete();
$emailUpdate = $user->emailUpdate()->create([
'email' => $newEmail,
'token' => $token
$token = $user->tokens()->create([
'type' => 'email-update',
'data' => [
'email' => $newEmail,
],
'expires_at' => now()->addMinutes(30),
]);
dispatch(new SendEmail($user->email, new EmailUpdateLink($token, $user->getName(), $user->email, $newEmail)))->onQueue('mail');
dispatch(new SendEmail($user->email, new UserEmailUpdateRequest($token->id, $user->email, $newEmail)))->onQueue('mail');
}
$userData['subscribed'] = ($request->get('subscribed', false) === 'on');

View File

@@ -3,57 +3,89 @@
namespace App\Http\Controllers;
use App\Jobs\SendEmail;
use App\Mail\LoginLink;
use App\Mail\RegisterLink;
use App\Models\EmailSubscriptions;
use App\Models\EmailUpdate;
use App\Mail\UserEmailUpdateConfirm;
use App\Mail\UserLogin;
use App\Mail\UserRegister;
use App\Mail\UserWelcome;
use App\Models\Token;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
class AuthController extends Controller
{
public function showLogin(Request $request) {
/**
* Show the login form or if token present, process the login
*
* @param Request $request
* @return View|RedirectResponse
*/
public function showLogin(Request $request): View|RedirectResponse
{
if (auth()->check()) {
// return redirect()->route('dashboard');
return redirect()->action([HomeController::class, 'index']);
}
$token = $request->query('token');
if ($token) {
return $this->tokenLogin($token);
return $this->LoginByToken($token);
}
return view('auth.login');
}
public function tokenLogin($token)
/**
* Process the login form
*
* @param Request $request
* @return View|RedirectResponse
*/
public function postLogin(Request $request): View|RedirectResponse
{
$loginToken = DB::table('login_tokens')->where('token', $token)->first();
$request->validate([
'email' => 'required|email',
], [
'email.required' => __('validation.custom_messages.email_required'),
'email.email' => __('validation.custom_messages.email_invalid'),
]);
if ($loginToken) {
$user = User::where('email', $loginToken->email)->first();
$intended_url = $loginToken->intended_url;
$user = User::where('email', $request->email)->whereNotNull('email_verified_at')->first();
if($user) {
$token = $user->tokens()->create([
'type' => 'login',
'data' => ['url' => session()->pull('url.intended', null)],
]);
DB::table('login_tokens')->where('token', $token)->delete();
dispatch(new SendEmail($user->email, new UserLogin($token->id, $user->getName(), $user->email)))->onQueue('mail');
return view('auth.login-link');
}
if ($user) {
Auth::login($user);
session()->flash('status', 'not-found');
return view('auth.login');
}
$user->markEmailAsVerified();
DB::table('login_tokens')->where('token', $token)->delete();
session()->flash('message', 'You have been logged in');
session()->flash('message-title', 'Logged in');
session()->flash('message-type', 'success');
/**
* Process the login by token
*
* @param string $tokenStr
* @return View|RedirectResponse
*/
public function loginByToken(string $tokenStr): View|RedirectResponse
{
$token = Token::where('id', $tokenStr)
->where('type', 'login')
->where('expires_at', '>', now())
->first();
if($intended_url) {
return redirect($intended_url);
}
return redirect()->action([HomeController::class, 'index']);
if ($token) {
$user = $token->user;
if($user) {
$token->delete();
return $this->loginByUser($user, $token->data);
}
}
@@ -63,27 +95,40 @@ class AuthController extends Controller
return view('auth.login');
}
public function postLogin(Request $request) {
$request->validate([
'email' => 'required|email',
], [
'email.required' => __('validation.custom_messages.email_required'),
'email.email' => __('validation.custom_messages.email_invalid'),
]);
$user = User::where('email', $request->email)->first();
if($user) {
$token = $user->createLoginToken(session()->pull('url.intended', null));
dispatch(new SendEmail($user->email, new LoginLink($token, $user->getName(), $user->email)))->onQueue('mail');
return view('auth.login-link');
/**
* Process the login by user
*
* @param User $user
* @param array $data
* @return RedirectResponse
*/
public function loginByUser(User $user, array $data = [])
{
$url = null;
if($data && isset($data->url) && $data->url) {
$url = $data->url;
}
session()->flash('status', 'not-found');
return view('auth.login');
Auth::login($user);
session()->flash('message', 'You have been logged in');
session()->flash('message-title', 'Logged in');
session()->flash('message-type', 'success');
if($url) {
return redirect($url);
}
return redirect()->action([HomeController::class, 'index']);
}
public function logout() {
/**
* Process the user logout
*
* @return RedirectResponse
*/
public function logout(): RedirectResponse
{
auth()->logout();
session()->flash('message', 'You have been logged out');
@@ -92,15 +137,57 @@ class AuthController extends Controller
return redirect()->route('index');
}
public function showRegister(Request $request) {
/**
* Show the registration form or if token present, process the registration
*
* @param Request $request
* @return View|RedirectResponse
*/
public function showRegister(Request $request): View|RedirectResponse
{
if (auth()->check()) {
return redirect()->route('index');
}
$tokenStr = $request->query('token');
if ($tokenStr) {
$token = Token::where('id', $tokenStr)
->where('type', 'register')
->where('expires_at', '>', now())
->first();
if ($token) {
$user = $token->user;
if ($user) {
$user->email_verified_at = now();
$user->save();
$user->tokens()->where('type', 'register')->delete();
dispatch(new SendEmail($user->email, new UserWelcome($user->email)))->onQueue('mail');
$this->loginByUser($user);
return redirect()->route('index');
}
}
session()->flash('message', 'That token has expired or is invalid');
session()->flash('message-title', 'Registration failed');
session()->flash('message-type', 'danger');
}
return view('auth.register');
}
public function postRegister(Request $request) {
/**
* Process the registration form
*
* @param Request $request
* @return View|RedirectResponse
*/
public function postRegister(Request $request): View|RedirectResponse
{
$request->validate([
'email' => 'required|email',
], [
@@ -119,46 +206,65 @@ class AuthController extends Controller
]);
}
} else if($passHoneypot) {
$firstname = explode('@', $request->email)[0];
$user = User::create([
'firstname' => $firstname,
'email' => $request->email,
]);
EmailUpdate::where('email', $request->email)->delete();
}
if($passHoneypot) {
Log::channel('honeypot')->info('Valid key used for registration using email: ' . $request->email . ', ip address: ' . $request->ip() . ', user agent: ' . $request->userAgent());
$token = $user->createLoginToken(session()->pull('url.intended', null));
dispatch(new SendEmail($user->email, new RegisterLink($token, $user->getName(), $user->email)))->onQueue('mail');
$user->tokens()->where('type', 'register')->delete();
$token = $user->tokens()->create([
'type' => 'register',
'data' => ['url' => session()->pull('url.intended', null)],
]);
dispatch(new SendEmail($user->email, new UserRegister($token->id, $user->email)))->onQueue('mail');
} else {
Log::channel('honeypot')->info('Invalid key used for registration using email: ' . $request->email . ', ip address: ' . $request->ip() . ', user agent: ' . $request->userAgent() . ', key: ' . $key);
}
return view('auth.login-link');
return view('auth.register-link');
}
public function updateEmail(Request $request)
/**
* Confirm the user email update.
*
* @param Request $request
* @return RedirectResponse
*/
public function updateEmail(Request $request): RedirectResponse
{
$token = $request->query('token');
$emailUpdate = EmailUpdate::where('token', $token)->first();
if($emailUpdate && $emailUpdate->user) {
$emailUpdate->user->email = $emailUpdate->email;
$emailUpdate->user->email_verified_at = now();
$emailUpdate->user->save();
$emailUpdate->delete();
$tokenStr = $request->query('token');
session()->flash('message', 'Your email has been updated');
session()->flash('message-title', 'Email updated');
session()->flash('message-type', 'success');
return redirect()->route('index');
$token = Token::where('id', $tokenStr)
->where('type', 'email-update')
->where('expires_at', '>', now())
->first();
if($token && $token->user) {
if($token->data && isset($token->data['email'])) {
$user = $token->user;
$user->email = $token->data['email'];
$user->email_verified_at = now();
$user->save();
$user->tokens()->where('type', 'email-update')->delete();
session()->flash('message', 'Your email has been updated');
session()->flash('message-title', 'Email updated');
session()->flash('message-type', 'success');
dispatch(new SendEmail($user->email, new UserEmailUpdateConfirm($user->email)))->onQueue('mail');
return redirect()->route('index');
}
}
session()->flash('message', 'That token has expired or is invalid');
session()->flash('message-title', 'Email update failed');
session()->flash('message-type', 'danger');
return redirect()->route('index');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class UserEmailUpdateConfirm extends Mailable
{
use Queueable, SerializesModels;
public $email;
public function __construct($email)
{
$this->email = $email;
}
public function build()
{
return $this
->subject('Your STEMMechanics account has been updated 👍')
->markdown('emails.email-update-confirm')
->with([
'email' => $this->email,
]);
}
}

View File

@@ -7,19 +7,17 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class EmailUpdateLink extends Mailable
class UserEmailUpdateRequest extends Mailable
{
use Queueable, SerializesModels;
public $token;
public $username;
public $email;
public $newEmail;
public function __construct($token, $username, $email, $newEmail)
public function __construct($token, $email, $newEmail)
{
$this->token = $token;
$this->username = $username;
$this->email = $email;
$this->newEmail = $newEmail;
}
@@ -27,11 +25,10 @@ class EmailUpdateLink extends Mailable
public function build()
{
return $this
->subject('Confirm new email address')
->markdown('emails.change-email-link')
->subject('Almost There! Confirm Your New Email Address 👍')
->markdown('emails.email-update-request')
->with([
'token' => $this->token,
'username' => $this->username,
'update_url' => route('update.email', ['token' => $this->token]),
'email' => $this->email,
'newEmail' => $this->newEmail,
]);

View File

@@ -7,7 +7,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class LoginLink extends Mailable
class UserLogin extends Mailable
{
use Queueable, SerializesModels;
@@ -25,10 +25,10 @@ class LoginLink extends Mailable
public function build()
{
return $this
->subject('Here\'s your login link')
->markdown('emails.login-link')
->subject('Here\'s your login link 🤫')
->markdown('emails.login')
->with([
'token' => $this->token,
'login_url' => route('login', ['token' => $this->token]),
'username' => $this->username,
'email' => $this->email,
]);

View File

@@ -7,29 +7,26 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class RegisterLink extends Mailable
class UserRegister extends Mailable
{
use Queueable, SerializesModels;
public $token;
public $username;
public $email;
public function __construct($token, $username, $email)
public function __construct($token, $email)
{
$this->token = $token;
$this->username = $username;
$this->email = $email;
}
public function build()
{
return $this
->subject('Here\'s your registration link')
->markdown('emails.register-link')
->subject('Almost There! Just One More Step to Join Us 🚀')
->markdown('emails.register')
->with([
'token' => $this->token,
'username' => $this->username,
'register_url' => route('register', ['token' => $this->token]),
'email' => $this->email,
]);
}

30
app/Mail/UserWelcome.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class UserWelcome extends Mailable
{
use Queueable, SerializesModels;
public $email;
public function __construct($email)
{
$this->email = $email;
}
public function build()
{
return $this
->subject('Welcome to STEMMechanics 🌟')
->markdown('emails.welcome')
->with([
'email' => $this->email,
]);
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class EmailUpdate extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'user_id',
'email',
'token'
];
/**
* Get the user that owns the email update.
*/
public function user()
{
return $this->belongsTo(User::class);
}
}

87
app/Models/Token.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class Token extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'type',
'data',
'expires_at',
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'expires_at' => 'datetime',
'data' => 'array',
];
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
/**
* The primary key for the model is incrementing.
*
* @var bool $incrementing
*/
public $incrementing = false;
/**
* The primary key type for the model.
*
* @var string
*/
public $keyType = 'string';
/**
* The "booted" method of the model.
*
* @return void
*/
public static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->{$model->getKeyName()}) === true) {
do {
$newToken = Str::random(48);
} while (self::where($model->getKeyName(), $newToken)->exists());
$model->{$model->getKeyName()} = $newToken;
}
if (empty($model->expires_at) === true) {
$model->expires_at = now()->addMinutes(10);
}
});
}
/**
* Get the user that the token belongs to.
*
* @return BelongsTo
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -2,17 +2,12 @@
namespace App\Models;
use App\Mail\LoginLink;
use App\Traits\UUID;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use PharIo\Manifest\Email;
class User extends Authenticatable implements MustVerifyEmail
{
@@ -110,34 +105,21 @@ class User extends Authenticatable implements MustVerifyEmail
});
}
public function createLoginToken($redirect = null)
/**
* Get the tokens for the user.
*
* @return HasMany
*/
public function tokens(): HasMany
{
// Generate a unique token
$token = Str::random(60);
// Store the token in the database
DB::table('login_tokens')->insert([
'email' => $this->email,
'token' => $token,
'intended_url' => $redirect,
]);
return $token;
}
public function softDelete()
{
foreach ($this->fillable as $field) {
if ($field === 'email_verified_at') {
$this->email_verified_at = null;
} else if ($field !== 'email') {
$this->{$field} = '';
}
}
$this->save();
return $this->hasMany(Token::class);
}
/**
* Get the calculated name of the user.
*
* @return string
*/
public function getName(): string
{
$name = '';
@@ -183,14 +165,11 @@ class User extends Authenticatable implements MustVerifyEmail
}
}
public function emailUpdate()
{
return $this->hasOne(EmailUpdate::class);
}
public function getEmailUpdatePendingAttribute()
{
return $this->emailUpdate()->exists();
$emailUpdate = $this->tokens()->where('type', 'email-update')->where('expires_at', '>', now())->first();
return $emailUpdate ? $emailUpdate->data['email'] : null;
}
public function isAdmin(): bool