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