@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\CandidateAccessLink;
|
||||
use App\Models\CandidateAccount;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class CandidateAccess
|
||||
{
|
||||
public function deliveryAvailable(): bool
|
||||
{
|
||||
$transport = config('mail.mailers.'.config('mail.default').'.transport');
|
||||
|
||||
return in_array($transport, ['smtp', 'ses', 'postmark', 'resend', 'sendmail'], true);
|
||||
}
|
||||
|
||||
public function request(int $company, string $email, string $purpose): void
|
||||
{
|
||||
abort_unless(in_array($purpose, ['verify', 'reset'], true), 422);
|
||||
if (! $this->deliveryAvailable()) {
|
||||
return; // Never put secret access links in the log or array mail transport.
|
||||
}
|
||||
$delivery = DB::transaction(function () use ($company, $email, $purpose) {
|
||||
$account = CandidateAccount::query()->where('tenant_id', $company)->where('email', mb_strtolower(trim($email)))->lockForUpdate()->first();
|
||||
if ($account === null || ($purpose === 'verify' && $account->email_verified_at !== null)) {
|
||||
return null;
|
||||
}
|
||||
$last = DB::table('candidate_access_tokens')->where('candidate_account_id', $account->id)->where('purpose', $purpose)->latest('id')->first();
|
||||
if ($last !== null && now()->diffInSeconds($last->created_at, absolute: true) < 60) {
|
||||
return null;
|
||||
}
|
||||
DB::table('candidate_access_tokens')->where('candidate_account_id', $account->id)->where('purpose', $purpose)->whereNull('consumed_at')->update(['consumed_at' => now()]);
|
||||
$token = Str::random(64);
|
||||
$id = DB::table('candidate_access_tokens')->insertGetId(['tenant_id' => $company, 'candidate_account_id' => $account->id, 'purpose' => $purpose,
|
||||
'token_hash' => hash('sha256', $token), 'expires_at' => now()->addMinutes($purpose === 'verify' ? 60 : 30), 'created_at' => now()]);
|
||||
app(CandidateProfiles::class)->event($account, 'access.link_requested', ['purpose' => $purpose, 'token_id' => $id]);
|
||||
|
||||
return ['account' => $account, 'token' => $token, 'id' => $id];
|
||||
});
|
||||
if ($delivery === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$path = $purpose === 'verify' ? 'confirmar-email' : 'recuperar';
|
||||
// Fragment is not sent in HTTP requests or Referer headers. The form submits the token by POST.
|
||||
$link = rtrim((string) config('app.url'), '/').'/candidaturas/'.$company.'/'.$path.'#token='.$delivery['token'];
|
||||
Mail::to($delivery['account']->email)->send(new CandidateAccessLink($purpose, $link));
|
||||
} catch (Throwable) {
|
||||
DB::table('candidate_access_tokens')->where('id', $delivery['id'])->update(['consumed_at' => now()]);
|
||||
app(CandidateProfiles::class)->event($delivery['account'], 'access.delivery_failed', ['purpose' => $purpose, 'token_id' => $delivery['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function consume(int $company, string $purpose, array $input): void
|
||||
{
|
||||
abort_unless(in_array($purpose, ['verify', 'reset'], true), 422);
|
||||
$data = Validator::make($input, ['token' => 'required|string|size:64', ...($purpose === 'reset' ? ['password' => 'required|string|min:12|max:128|confirmed'] : [])])->validate();
|
||||
DB::transaction(function () use ($company, $purpose, $data) {
|
||||
$token = DB::table('candidate_access_tokens')->where('tenant_id', $company)->where('purpose', $purpose)->where('token_hash', hash('sha256', $data['token']))->first();
|
||||
if ($token === null) {
|
||||
$this->invalid();
|
||||
}
|
||||
$account = CandidateAccount::query()->where('tenant_id', $company)->where('id', $token->candidate_account_id)->lockForUpdate()->firstOrFail();
|
||||
$token = DB::table('candidate_access_tokens')->where('id', $token->id)->lockForUpdate()->first();
|
||||
if ($token === null || $token->consumed_at !== null || now()->greaterThanOrEqualTo($token->expires_at)) {
|
||||
$this->invalid();
|
||||
}
|
||||
if ($purpose === 'verify') {
|
||||
if ($account->email_verified_at === null) {
|
||||
$account->setAttribute('email_verified_at', now());
|
||||
}
|
||||
} else {
|
||||
$account->password = $data['password'];
|
||||
$account->session_version++;
|
||||
DB::table('candidate_access_tokens')->where('candidate_account_id', $account->id)->where('purpose', 'reset')->whereNull('consumed_at')->update(['consumed_at' => now()]);
|
||||
}
|
||||
$account->save();
|
||||
DB::table('candidate_access_tokens')->where('id', $token->id)->update(['consumed_at' => now()]);
|
||||
app(CandidateProfiles::class)->event($account, $purpose === 'verify' ? 'account.email_verified' : 'account.password_reset', ['token_id' => $token->id]);
|
||||
});
|
||||
}
|
||||
|
||||
private function invalid(): never
|
||||
{
|
||||
throw ValidationException::withMessages(['token' => 'A ligação é inválida, expirou ou já foi utilizada. Peça uma nova ligação.']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user