Files
wordpress-theme-aksrt/src/Auth/OAuthTransactionStore.php
T
2026-08-29 22:03:48 +08:00

209 lines
6.0 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Auth;
defined('ABSPATH') || exit;
final class OAuthTransactionStore {
private const PREFIX = 'aksrt_oauth_tx_';
private const COOKIE = 'aksrt_oauth_browser';
private const LOCK_TTL = 30;
public static function browser_id(): string {
$value = isset($_COOKIE[self::COOKIE]) ? sanitize_text_field(wp_unslash((string) $_COOKIE[self::COOKIE])) : '';
if (preg_match('/^[A-Za-z0-9_-]{32,80}$/', $value) === 1) {
return $value;
}
$value = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
if (!headers_sent()) {
setcookie(self::COOKIE, $value, [
'expires' => time() + MONTH_IN_SECONDS,
'path' => defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/',
'domain' => defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : '',
'secure' => is_ssl(),
'httponly' => true,
'samesite' => 'Lax',
]);
}
$_COOKIE[self::COOKIE] = $value;
return $value;
}
public static function issue(string $kind, array $payload, int $ttl = 600, bool $bind_browser = true): string {
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$key = self::key($token);
$row = [
'kind' => sanitize_key($kind),
'payload' => $payload,
'browser_hash' => $bind_browser ? hash('sha256', self::browser_id()) : '',
'created_at' => time(),
'expires_at' => time() + max(30, $ttl),
'claimed_at' => 0,
];
if (!add_option($key, $row, '', false)) {
throw new \RuntimeException('oauth_transaction_issue_failed');
}
return $token;
}
public static function peek(string $token, string $kind = '', bool $require_browser = true): ?array {
$key = self::key($token);
$row = get_option($key, null);
if (is_array($row) && (int) ($row['expires_at'] ?? 0) < time()) {
delete_option($key);
return null;
}
if (!self::valid_row($row, $kind, $require_browser)) {
return null;
}
return $row;
}
public static function claim(string $token, string $kind = '', bool $require_browser = true): ?array {
global $wpdb;
$key = self::key($token);
$row = get_option($key, null);
if (is_array($row) && (int) ($row['expires_at'] ?? 0) < time()) {
delete_option($key);
return null;
}
if (!self::valid_row($row, $kind, $require_browser)) {
return null;
}
$serialized = maybe_serialize($row);
$row['claimed_at'] = time();
$deleted = $wpdb->query($wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
$key,
$serialized
));
if ($deleted !== 1) {
return null;
}
wp_cache_delete($key, 'options');
return $row;
}
public static function update(string $token, string $kind, callable $mutator, bool $require_browser = false): bool {
global $wpdb;
$key = self::key($token);
$current = get_option($key, null);
if (is_array($current) && (int) ($current['expires_at'] ?? 0) < time()) {
delete_option($key);
return false;
}
if (!self::valid_row($current, $kind, $require_browser)) {
return false;
}
$next = $mutator($current);
if (!is_array($next)) {
return false;
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
maybe_serialize($next),
$key,
maybe_serialize($current)
));
if ($updated === 1) {
wp_cache_delete($key, 'options');
return true;
}
return false;
}
public static function delete(string $token): void {
delete_option(self::key($token));
}
public static function acquire_lock(string $scope, int $ttl = self::LOCK_TTL) {
$key = self::lock_key($scope);
$token = wp_generate_uuid4();
$value = ['token' => $token, 'expires_at' => time() + max(5, $ttl)];
if (add_option($key, $value, '', false)) {
return $token;
}
$current = get_option($key, null);
if (!is_array($current) || (int) ($current['expires_at'] ?? 0) >= time()) {
return false;
}
global $wpdb;
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
maybe_serialize($value),
$key,
maybe_serialize($current)
));
if ($updated === 1) {
wp_cache_delete($key, 'options');
return $token;
}
return false;
}
public static function release_lock(string $scope, string $token): void {
global $wpdb;
$key = self::lock_key($scope);
$current = get_option($key, null);
if (!is_array($current) || !hash_equals((string) ($current['token'] ?? ''), $token)) {
return;
}
$deleted = $wpdb->query($wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
$key,
maybe_serialize($current)
));
if ($deleted === 1) {
wp_cache_delete($key, 'options');
}
}
public static function cleanup(int $limit = 200): int {
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT %d",
$wpdb->esc_like(self::PREFIX) . '%',
max(1, min(1000, $limit))
), ARRAY_A);
$removed = 0;
foreach (is_array($rows) ? $rows : [] as $row) {
$value = maybe_unserialize($row['option_value'] ?? '');
if (!is_array($value) || (int) ($value['expires_at'] ?? 0) >= time()) {
continue;
}
if (delete_option((string) $row['option_name'])) {
$removed++;
}
}
return $removed;
}
private static function valid_row($row, string $kind, bool $require_browser): bool {
if (!is_array($row) || (int) ($row['expires_at'] ?? 0) < time()) {
return false;
}
if ($kind !== '' && !hash_equals(sanitize_key($kind), (string) ($row['kind'] ?? ''))) {
return false;
}
if ($require_browser) {
$expected = (string) ($row['browser_hash'] ?? '');
if ($expected === '' || !hash_equals($expected, hash('sha256', self::browser_id()))) {
return false;
}
}
return true;
}
private static function key(string $token): string {
return self::PREFIX . hash('sha256', trim($token));
}
private static function lock_key(string $scope): string {
return self::PREFIX . 'lock_' . hash('sha256', $scope);
}
}