Files
wordpress-theme-aksrt/inc/classes/auth/class-aksrt-auth-service.php
T
kate522 ce1c13b947 refactor(classes): organize inc/classes into domain folders
Move ~94 root class shells and root *-traits into domain directories
(core, auth, user, credits, content, payment/gateways, forum, shop,
sms, ai, messages, extensions, widgets, settings, admin). Update
bootstrap require paths, co-locate traits with owners, fix forum/user
relative requires, drop duplicate shop controller requires, and wire
home-modules load. Path-only; class names and init order unchanged.
2026-07-17 11:11:57 +08:00

422 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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')];
}
}
}
if (function_exists('aksrt_require_captcha')) {
$captcha_result = aksrt_require_captcha('login', $post);
if ($captcha_result !== true) {
return ['success' => false, 'error' => $captcha_result];
}
}
$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,
];
}
}