95 lines
5.2 KiB
PHP
95 lines
5.2 KiB
PHP
<?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.']);
|
|
}
|
|
}
|