- Popup/banner quảng cáo cấu hình từ admin (loại, link, lịch hiển thị) - Đặt lại mật khẩu: token 80 ký tự kèm check digit Luhn thay cho token+email lộ trong URL, chỉ lưu bản băm, một lần dùng, hết hạn 60 phút - Xác thực 2 lớp qua email khi đăng nhập (mã 6 số, ghi nhớ thiết bị 90 ngày) - ApiAuthCheck chặn ngay tài khoản bị khoá dù JWT còn hạn (trước đây dùng được tới hết TTL) - Handler trả 404 thay vì 500 khi ModelNotFoundException không bọc HttpException - Test ClientTamperingTest: xác nhận IDOR/giả mạo dữ liệu từ client đều bị chặn Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
114 lines
3.1 KiB
PHP
114 lines
3.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support;
|
|
|
|
use App\Models\Customer;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
final class PasswordResetToken
|
|
{
|
|
public const LENGTH = 80;
|
|
|
|
private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
|
|
private const TABLE = 'password_resets';
|
|
|
|
/**
|
|
* @return string|null null khi email vừa được cấp token trong khoảng throttle
|
|
*/
|
|
public static function issue(Customer $customer): ?string
|
|
{
|
|
$email = strtolower($customer->email);
|
|
$lastIssuedAt = DB::table(self::TABLE)->where('email', $email)->max('created_at');
|
|
|
|
if ($lastIssuedAt && now()->subSeconds(self::throttleSeconds())->lt($lastIssuedAt)) {
|
|
return null;
|
|
}
|
|
|
|
$body = '';
|
|
for ($i = 0; $i < self::LENGTH - 1; $i++) {
|
|
$body .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
|
|
}
|
|
$token = $body . self::checkCharacter($body);
|
|
|
|
DB::transaction(function () use ($email, $token) {
|
|
DB::table(self::TABLE)->where('email', $email)->delete();
|
|
DB::table(self::TABLE)->insert([
|
|
'email' => $email,
|
|
'token' => self::hash($token),
|
|
'created_at' => now(),
|
|
]);
|
|
});
|
|
|
|
return $token;
|
|
}
|
|
|
|
public static function isWellFormed(string $token): bool
|
|
{
|
|
if (strlen($token) !== self::LENGTH || strspn($token, self::ALPHABET) !== self::LENGTH) {
|
|
return false;
|
|
}
|
|
|
|
return self::checkCharacter(substr($token, 0, -1)) === $token[self::LENGTH - 1];
|
|
}
|
|
|
|
public static function findCustomer(string $token): ?Customer
|
|
{
|
|
if (!self::isWellFormed($token)) {
|
|
return null;
|
|
}
|
|
|
|
$row = DB::table(self::TABLE)
|
|
->where('token', self::hash($token))
|
|
->where('created_at', '>=', now()->subMinutes(self::expireMinutes()))
|
|
->first();
|
|
|
|
if (!$row) {
|
|
return null;
|
|
}
|
|
|
|
return Customer::whereRaw('LOWER(email) = ?', [$row->email])->first();
|
|
}
|
|
|
|
public static function revokeFor(Customer $customer): void
|
|
{
|
|
DB::table(self::TABLE)->where('email', strtolower($customer->email))->delete();
|
|
}
|
|
|
|
private static function checkCharacter(string $body): string
|
|
{
|
|
$base = strlen(self::ALPHABET);
|
|
$sum = 0;
|
|
$double = true;
|
|
|
|
for ($i = strlen($body) - 1; $i >= 0; $i--) {
|
|
$value = strpos(self::ALPHABET, $body[$i]);
|
|
if ($double) {
|
|
$value *= 2;
|
|
$value = intdiv($value, $base) + $value % $base;
|
|
}
|
|
$sum += $value;
|
|
$double = !$double;
|
|
}
|
|
|
|
return self::ALPHABET[($base - $sum % $base) % $base];
|
|
}
|
|
|
|
private static function hash(string $token): string
|
|
{
|
|
return hash('sha256', $token);
|
|
}
|
|
|
|
private static function expireMinutes(): int
|
|
{
|
|
return (int) config('auth.passwords.users.expire', 60);
|
|
}
|
|
|
|
private static function throttleSeconds(): int
|
|
{
|
|
return (int) config('auth.passwords.users.throttle', 60);
|
|
}
|
|
}
|