415 lines
13 KiB
PHP
415 lines
13 KiB
PHP
<?php
|
||
/**
|
||
* AKSRT Theme Auth Service
|
||
*
|
||
* 统一处理主题内认证业务:
|
||
* - 注册时直接设置密码
|
||
* - 主题自己的忘记密码 token 机制
|
||
* - 重置密码
|
||
*
|
||
* @package AKSRT
|
||
*/
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
final class AKSRT_Auth_Service {
|
||
use AKSRT_Auth_Service_Part_1a;
|
||
use AKSRT_Auth_Service_Part_1b;
|
||
|
||
|
||
private const RESET_TOKEN_HASH_META = 'aksrt_password_reset_token_hash';
|
||
private const RESET_TOKEN_EXPIRES_META = 'aksrt_password_reset_expires';
|
||
private const RESET_TOKEN_REQUESTED_AT = 'aksrt_password_reset_requested_at';
|
||
private const RESET_TOKEN_TTL = 3600;
|
||
public const EMAIL_VERIFIED_META = 'aksrt_email_verified';
|
||
/**
|
||
* 用户当时被发送的验证方式('link' | 'code')。
|
||
*
|
||
* 注意:这是 *审计字段*,仅用于后台展示「上一次给该用户发的是哪种验证邮件」。
|
||
* 路由分支(验证页应该显示链接提示还是验证码输入框、自助/管理员重发使用哪种方式)
|
||
* 一律读取全局配置 self::current_registration_verification_method(),
|
||
* 这样管理员把站点从 code 切换到 link 后,所有在途用户都立即跟随新方式。
|
||
* 不要根据这个 meta 决定页面模式或重发方式。
|
||
*/
|
||
public const EMAIL_METHOD_META = 'aksrt_email_verification_method';
|
||
public const EMAIL_TOKEN_HASH_META = 'aksrt_email_verification_token_hash';
|
||
public const EMAIL_CODE_HASH_META = 'aksrt_email_verification_code_hash';
|
||
public const EMAIL_EXPIRES_META = 'aksrt_email_verification_expires';
|
||
public const EMAIL_REQUESTED_AT_META = 'aksrt_email_verification_requested_at';
|
||
public const EMAIL_TARGET_META = 'aksrt_email_verification_email';
|
||
public const EMAIL_OBK_META = 'aksrt_email_verification_obk';
|
||
private const EMAIL_ACCESS_KEY_META = 'aksrt_email_verification_access_key';
|
||
public const EMAIL_CODE_ATTEMPTS_META = 'aksrt_email_verification_code_attempts';
|
||
public const EMAIL_CODE_MAX_ATTEMPTS = 5;
|
||
public const EMAIL_CODE_IP_TTL = 900;
|
||
// 自助重发的 IP 节流:每 IP 每 RESEND_IP_WINDOW 秒最多 RESEND_IP_MAX 次。
|
||
private const RESEND_IP_MAX = 6;
|
||
private const RESEND_IP_WINDOW = 600;
|
||
|
||
/**
|
||
* 注册流程内的"待消费"邀请码上下文。
|
||
*
|
||
* 由 register_user() 在 wp_create_user 之前写入,由 AKSRT_Invite::on_user_register
|
||
* 通过 take_pending_invite_context() 一次性消费。
|
||
*
|
||
* 设计为请求级静态:避免污染 transient 表,也避免依赖 cookie 在跨标签场景下的不确定性。
|
||
*
|
||
* @var int
|
||
*/
|
||
private static $pending_invite_inviter_id = 0;
|
||
/** @var string */
|
||
private static $pending_invite_code = '';
|
||
|
||
/**
|
||
* 取出并清空当前注册请求的"表单 invite_code 解析结果"。
|
||
*
|
||
* @return array{inviter_id:int, code:string}
|
||
*/
|
||
public static function take_pending_invite_context(): array {
|
||
$ctx = [
|
||
'inviter_id' => (int) self::$pending_invite_inviter_id,
|
||
'code' => (string) self::$pending_invite_code,
|
||
];
|
||
self::$pending_invite_inviter_id = 0;
|
||
self::$pending_invite_code = '';
|
||
return $ctx;
|
||
}
|
||
|
||
/**
|
||
* 启动钩子:注册全局 authenticate 拦截器(阶段 4)
|
||
* 调用方:bootstrap.php 在所有类加载完成后调用一次。
|
||
*/
|
||
public static function init(): void {
|
||
add_filter('authenticate', [self::class, 'enforce_email_verification_on_authenticate'], 30, 3);
|
||
}
|
||
|
||
/**
|
||
* 全局登录拦截器:堵住 wp-login.php / REST / XML-RPC / Application Password 等所有
|
||
* 不经过 AKSRT_Auth_Service::login_user() 的认证路径。
|
||
*
|
||
* 多重短路保护,按"成本最低"顺序排列:
|
||
* 1. 紧急降级常量 AKSRT_NO_EMAIL_GATE
|
||
* 2. $user 不是 WP_User(认证失败 / 未开始)
|
||
* 3. WP_Error(前置错误,原样返回)
|
||
* 4. 功能未启用
|
||
* 5. 管理员豁免(manage_options)
|
||
* 6. cron / 安装 / 升级豁免
|
||
* 7. REST/XML-RPC 阶段化(option aksrt_email_verification_rest_block_at 控制)
|
||
* 8. 自定义豁免过滤器
|
||
*
|
||
* @param mixed $user
|
||
* @param string $username
|
||
* @param string $password
|
||
* @return mixed
|
||
*/
|
||
public static function enforce_email_verification_on_authenticate($user, $username = '', $password = '') {
|
||
if (defined('AKSRT_NO_EMAIL_GATE') && AKSRT_NO_EMAIL_GATE) {
|
||
return $user;
|
||
}
|
||
if (!($user instanceof WP_User)) {
|
||
return $user;
|
||
}
|
||
if (is_wp_error($user)) {
|
||
return $user;
|
||
}
|
||
if (!class_exists('AKSRT_Settings') || !AKSRT_Settings::registration_requires_email_verification()) {
|
||
return $user;
|
||
}
|
||
if (user_can($user, 'manage_options')) {
|
||
return $user;
|
||
}
|
||
if (defined('DOING_CRON') && DOING_CRON) {
|
||
return $user;
|
||
}
|
||
if (defined('WP_INSTALLING') && WP_INSTALLING) {
|
||
return $user;
|
||
}
|
||
|
||
$is_rest = (defined('REST_REQUEST') && REST_REQUEST);
|
||
$is_xmlrpc = (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST);
|
||
$rest_block_at = (int) get_option('aksrt_email_verification_rest_block_at', 0);
|
||
|
||
// 阶段化:option=0 时 REST/XML-RPC 仅记录不拦截(Day 0~3 灰度期)
|
||
if (($is_rest || $is_xmlrpc) && $rest_block_at <= 0) {
|
||
if (!AKSRT_Auth_Email_Verification::is_email_verified((int) $user->ID)) {
|
||
self::log_event('auth.rest_unverified', [
|
||
'uid' => (int) $user->ID,
|
||
'rest' => $is_rest,
|
||
'xmlrpc' => $is_xmlrpc,
|
||
]);
|
||
}
|
||
return $user;
|
||
}
|
||
|
||
// 已验证 → 直接放行
|
||
if (AKSRT_Auth_Email_Verification::is_email_verified((int) $user->ID)) {
|
||
return $user;
|
||
}
|
||
|
||
// 自定义豁免:filter 返回 false 即放行
|
||
$should_block = apply_filters('aksrt_email_verification_should_block', true, $user, [
|
||
'rest' => $is_rest,
|
||
'xmlrpc' => $is_xmlrpc,
|
||
]);
|
||
if (!$should_block) {
|
||
return $user;
|
||
}
|
||
|
||
self::log_event('auth.blocked', [
|
||
'uid' => (int) $user->ID,
|
||
'rest' => $is_rest,
|
||
'xmlrpc' => $is_xmlrpc,
|
||
]);
|
||
|
||
return new WP_Error(
|
||
'aksrt_email_unverified',
|
||
__('账号尚未完成邮箱验证,请先完成邮箱验证后再登录。', 'aksrt'),
|
||
[
|
||
'verify_url' => self::get_email_verification_url((int) $user->ID),
|
||
]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 轻量事件日志(ring buffer,最近 200 条),用于健康检查路由统计。
|
||
* 不写入文件,仅写 transient(24h TTL),不影响性能。
|
||
*/
|
||
public static function log_event(string $code, array $context = []): void {
|
||
$key = 'aksrt_email_verify_log';
|
||
$logs = get_transient($key);
|
||
if (!is_array($logs)) {
|
||
$logs = [];
|
||
}
|
||
$logs[] = [
|
||
't' => time(),
|
||
'c' => $code,
|
||
'd' => $context,
|
||
];
|
||
// 保留最近 200 条
|
||
if (count($logs) > 200) {
|
||
$logs = array_slice($logs, -200);
|
||
}
|
||
set_transient($key, $logs, DAY_IN_SECONDS);
|
||
}
|
||
|
||
/**
|
||
* 读取最近 24h 的事件日志聚合。
|
||
*/
|
||
public static function get_email_verification_url(int $user_id, array $args = []): string {
|
||
if ($user_id <= 0 || !function_exists('aksrt_get_verify_email_url')) {
|
||
return '';
|
||
}
|
||
|
||
$access_key = self::get_or_issue_email_access_key($user_id);
|
||
if ($access_key !== '') {
|
||
$args['vk'] = $access_key;
|
||
}
|
||
|
||
return aksrt_get_verify_email_url($user_id, $args);
|
||
}
|
||
|
||
public static function current_registration_verification_method(): string {
|
||
if (!class_exists('AKSRT_Settings')) {
|
||
return 'link';
|
||
}
|
||
|
||
$method = AKSRT_Settings::registration_verification_method();
|
||
return in_array($method, ['link', 'code'], true) ? $method : 'link';
|
||
}
|
||
|
||
private static function get_or_issue_email_access_key(int $user_id, bool $rotate = false): string {
|
||
if ($user_id <= 0) {
|
||
return '';
|
||
}
|
||
|
||
$current = (string) get_user_meta($user_id, self::EMAIL_ACCESS_KEY_META, true);
|
||
if (!$rotate && $current !== '') {
|
||
return $current;
|
||
}
|
||
|
||
$key = wp_generate_password(48, false, false);
|
||
update_user_meta($user_id, self::EMAIL_ACCESS_KEY_META, $key);
|
||
return $key;
|
||
}
|
||
|
||
private static function has_valid_email_access_key(int $user_id, string $access_key): bool {
|
||
if ($user_id <= 0 || $access_key === '') {
|
||
return false;
|
||
}
|
||
|
||
$stored = (string) get_user_meta($user_id, self::EMAIL_ACCESS_KEY_META, true);
|
||
return $stored !== '' && hash_equals($stored, $access_key);
|
||
}
|
||
|
||
private static function masked_email(string $email): string {
|
||
if ($email === '' || !is_email($email) || strpos($email, '@') === false) {
|
||
return '';
|
||
}
|
||
|
||
[$local, $domain] = explode('@', $email, 2);
|
||
$local_visible = function_exists('mb_substr') ? mb_substr($local, 0, 2, 'UTF-8') : substr($local, 0, 2);
|
||
$local_masked = $local_visible . str_repeat('*', max(1, strlen($local) - strlen($local_visible)));
|
||
|
||
$domain_parts = explode('.', $domain);
|
||
$domain_head = $domain_parts[0] ?? '';
|
||
$domain_visible = substr($domain_head, 0, 1);
|
||
$domain_parts[0] = $domain_visible . str_repeat('*', max(1, strlen($domain_head) - strlen($domain_visible)));
|
||
|
||
return $local_masked . '@' . implode('.', $domain_parts);
|
||
}
|
||
|
||
public static function get_recent_log_summary(): array {
|
||
$logs = get_transient('aksrt_email_verify_log');
|
||
if (!is_array($logs)) {
|
||
return [];
|
||
}
|
||
$cutoff = time() - DAY_IN_SECONDS;
|
||
$summary = [];
|
||
foreach ($logs as $entry) {
|
||
if (!is_array($entry) || !isset($entry['t'], $entry['c'])) continue;
|
||
if ((int) $entry['t'] < $cutoff) continue;
|
||
$code = (string) $entry['c'];
|
||
$summary[$code] = ($summary[$code] ?? 0) + 1;
|
||
}
|
||
return $summary;
|
||
}
|
||
|
||
public static function get_oauth_bind_context(string $obk): ?array {
|
||
$obk = sanitize_text_field($obk);
|
||
if ($obk === '') {
|
||
return null;
|
||
}
|
||
|
||
$data = get_transient($obk);
|
||
return is_array($data) ? $data : null;
|
||
}
|
||
|
||
public static function get_oauth_provider_name(string $provider): string {
|
||
$provider_names = [
|
||
'github' => 'GitHub',
|
||
'gitee' => 'Gitee',
|
||
'qq' => 'QQ',
|
||
'google' => 'Google',
|
||
];
|
||
|
||
$provider = sanitize_key($provider);
|
||
return $provider_names[$provider] ?? ucfirst($provider);
|
||
}
|
||
|
||
public static function resolve_email_to_username(string $login): string {
|
||
$login = trim($login);
|
||
if (is_email($login)) {
|
||
$user = get_user_by('email', $login);
|
||
if ($user && !is_wp_error($user)) {
|
||
return $user->user_login;
|
||
}
|
||
}
|
||
return sanitize_user($login, true);
|
||
}
|
||
|
||
public static function friendly_login_error(string $raw_message): string {
|
||
$msg_lower = wp_strip_all_tags(strtolower($raw_message));
|
||
|
||
if (false !== strpos($msg_lower, 'incorrect password') || false !== strpos($msg_lower, '密码不正确')) {
|
||
return __('用户名或密码错误,请重新输入', 'aksrt');
|
||
}
|
||
|
||
if (
|
||
false !== strpos($msg_lower, 'invalid username') ||
|
||
false !== strpos($msg_lower, 'invalid email') ||
|
||
false !== strpos($msg_lower, '<strong>error</strong>: 该用户不存在')
|
||
) {
|
||
return __('账号不存在,请检查输入或先注册', 'aksrt');
|
||
}
|
||
|
||
if (
|
||
false !== strpos($msg_lower, 'empty_password') ||
|
||
false !== strpos($msg_lower, 'empty username') ||
|
||
false !== strpos($msg_lower, 'empty')
|
||
) {
|
||
return __('请填写用户名和密码', 'aksrt');
|
||
}
|
||
|
||
return sprintf(__('登录失败:%s', 'aksrt'), wp_strip_all_tags($raw_message));
|
||
}
|
||
|
||
public static function validate_password(string $password, string $confirm = '') {
|
||
if ($password === '') {
|
||
return __('请输入密码', 'aksrt');
|
||
}
|
||
|
||
if (mb_strlen($password) < 6) {
|
||
return __('密码长度至少6位', 'aksrt');
|
||
}
|
||
|
||
if (mb_strlen($password) > 128) {
|
||
return __('密码过长,请控制在128位以内', 'aksrt');
|
||
}
|
||
|
||
if ($confirm !== '' && $password !== $confirm) {
|
||
return __('两次输入的密码不一致', 'aksrt');
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
public static function login_user(array $post, bool $is_ajax = false, bool $skip_nonce = false): array {
|
||
$resolved_login = self::resolve_email_to_username((string) ($post['log'] ?? ''));
|
||
|
||
if (!$skip_nonce) {
|
||
if ($is_ajax) {
|
||
if (!wp_verify_nonce($post['_ajax_nonce'] ?? '', 'aksrt_ajax_login')) {
|
||
return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')];
|
||
}
|
||
} else {
|
||
if (!wp_verify_nonce($post['_login_nonce'] ?? '', 'aksrt_login')) {
|
||
return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')];
|
||
}
|
||
}
|
||
}
|
||
|
||
$creds = [
|
||
'user_login' => $resolved_login,
|
||
'user_password' => (string) ($post['pwd'] ?? ''),
|
||
'remember' => !empty($post['rememberme']),
|
||
];
|
||
|
||
$original_post = $_POST;
|
||
$_POST = array_merge($_POST, $post);
|
||
try {
|
||
$user = wp_signon($creds);
|
||
} finally {
|
||
$_POST = $original_post;
|
||
}
|
||
|
||
if (is_wp_error($user)) {
|
||
return ['success' => false, 'error' => self::friendly_login_error($user->get_error_message())];
|
||
}
|
||
|
||
// REST API 上下文中 wp_signon 可能无法正确设置 Cookie
|
||
// 手动调用 wp_set_auth_cookie 确保 Cookie 被设置
|
||
if (defined('REST_REQUEST') && REST_REQUEST) {
|
||
wp_set_auth_cookie($user->ID, true);
|
||
wp_set_current_user($user->ID);
|
||
}
|
||
|
||
if (self::user_requires_email_verification($user)) {
|
||
wp_logout();
|
||
return [
|
||
'success' => false,
|
||
'error' => __('账号尚未完成邮箱验证,请先验证邮箱后再登录', 'aksrt'),
|
||
'verify_url' => self::get_email_verification_url((int) $user->ID),
|
||
];
|
||
}
|
||
|
||
$redirect = !empty($post['redirect_to']) ? wp_unslash((string) $post['redirect_to']) : home_url('/');
|
||
|
||
return [
|
||
'success' => true,
|
||
'user' => $user,
|
||
'redirect' => $redirect,
|
||
];
|
||
}
|
||
|
||
}
|