Files
portal-api/app/Support/PasswordResetToken.php
baodev0andClaude Sonnet 5 12d5b4951c feat: popup quảng cáo, đặt lại mật khẩu bằng token 80 ký tự, 2FA email, vá lỗ hổng bảo mật
- 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>
2026-10-13 13:23:33 +07:00

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);
}
}