Files
compras/app/Services/CandidateAccess.php
T
nala c6373365bf
tests / ci (push) Canceled after 0s
commit9
2026-10-30 20:02:30 +02:00

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.']);
}
}