refactor: flatten src/AKSRT → src (drop theme-identifier dir)
Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
This commit is contained in:
1 parent
56f1a8175f
commit
8c5b1555c9
285 files changed
+26
-3600
No files matched your search
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
/**
|
||||
* CaiHong / rainbow aggregate OAuth client (zibll clogin parity).
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class CLoginClient {
|
||||
|
||||
private string $apiurl;
|
||||
private string $appid;
|
||||
private string $appkey;
|
||||
private string $callback;
|
||||
public string $state = '';
|
||||
|
||||
/**
|
||||
* @param array{apiurl:string,appid:string,appkey:string,callback?:string} $config
|
||||
*/
|
||||
public function __construct(array $config) {
|
||||
$this->apiurl = rtrim((string) ($config['apiurl'] ?? ''), '/') . '/connect.php';
|
||||
$this->appid = (string) ($config['appid'] ?? '');
|
||||
$this->appkey = (string) ($config['appkey'] ?? '');
|
||||
$this->callback = (string) ($config['callback'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function login(string $type): array {
|
||||
$this->state = md5(uniqid((string) wp_rand(), true));
|
||||
$params = [
|
||||
'act' => 'login',
|
||||
'appid' => $this->appid,
|
||||
'appkey' => $this->appkey,
|
||||
'type' => $this->to_api_type($type),
|
||||
'redirect_uri' => $this->callback,
|
||||
'state' => $this->state,
|
||||
];
|
||||
return $this->request($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function callback(string $code): array {
|
||||
return $this->request([
|
||||
'act' => 'callback',
|
||||
'appid' => $this->appid,
|
||||
'appkey' => $this->appkey,
|
||||
'code' => $code,
|
||||
]);
|
||||
}
|
||||
|
||||
public function to_api_type(string $type): string {
|
||||
switch (sanitize_key($type)) {
|
||||
case 'weixin':
|
||||
case 'wechat':
|
||||
return 'wx';
|
||||
case 'weibo':
|
||||
return 'sina';
|
||||
default:
|
||||
return sanitize_key($type);
|
||||
}
|
||||
}
|
||||
|
||||
public function from_api_type(string $type): string {
|
||||
switch (sanitize_key($type)) {
|
||||
case 'wx':
|
||||
return 'weixin';
|
||||
case 'sina':
|
||||
return 'weibo';
|
||||
default:
|
||||
return sanitize_key($type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $params
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function request(array $params): array {
|
||||
$url = $this->apiurl . '?' . http_build_query($params);
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => 15,
|
||||
'headers' => [
|
||||
'User-Agent' => 'AKSRT-CLogin/1.0',
|
||||
],
|
||||
]);
|
||||
if (is_wp_error($response)) {
|
||||
return [
|
||||
'code' => 1,
|
||||
'msg' => $response->get_error_message(),
|
||||
];
|
||||
}
|
||||
$body = (string) wp_remote_retrieve_body($response);
|
||||
$data = json_decode($body, true);
|
||||
return is_array($data) ? $data : [
|
||||
'code' => 1,
|
||||
'msg' => __('聚合登录接口返回异常', 'aksrt'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class EmailRollback {
|
||||
|
||||
const META_PREVIOUS = 'aksrt_email_previous';
|
||||
const META_CHANGED_AT = 'aksrt_email_changed_at';
|
||||
const WINDOW_SECONDS = 1209600; // 14 days
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_filter('user_row_actions', [$this, 'add_row_action'], 20, 2);
|
||||
add_action('admin_post_aksrt_email_rollback', [$this, 'handle_admin_rollback']);
|
||||
// cron 清理过期 meta
|
||||
add_action('aksrt_email_rollback_cleanup', [self::class, 'cron_cleanup']);
|
||||
add_action('wp', [self::class, 'maybe_schedule_cleanup']);
|
||||
}
|
||||
|
||||
public static function maybe_schedule_cleanup(): void {
|
||||
if (!wp_next_scheduled('aksrt_email_rollback_cleanup')) {
|
||||
wp_schedule_event(time() + DAY_IN_SECONDS, 'daily', 'aksrt_email_rollback_cleanup');
|
||||
}
|
||||
}
|
||||
|
||||
public static function cron_cleanup(): void {
|
||||
global $wpdb;
|
||||
$cutoff = time() - self::WINDOW_SECONDS;
|
||||
$rows = $wpdb->get_col($wpdb->prepare(
|
||||
"SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND CAST(meta_value AS UNSIGNED) < %d",
|
||||
self::META_CHANGED_AT,
|
||||
$cutoff
|
||||
));
|
||||
if (empty($rows)) return;
|
||||
foreach ($rows as $uid) {
|
||||
delete_user_meta((int) $uid, self::META_PREVIOUS);
|
||||
delete_user_meta((int) $uid, self::META_CHANGED_AT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 14 天窗口内?
|
||||
*/
|
||||
public static function is_within_window(int $user_id): bool {
|
||||
$changed_at = (int) get_user_meta($user_id, self::META_CHANGED_AT, true);
|
||||
if ($changed_at <= 0) return false;
|
||||
return (time() - $changed_at) <= self::WINDOW_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出窗口内可回滚的用户。
|
||||
*/
|
||||
public static function list_pending(int $page = 1, int $per_page = 20): array {
|
||||
global $wpdb;
|
||||
$page = max(1, $page);
|
||||
$per_page = min(100, max(1, $per_page));
|
||||
$offset = ($page - 1) * $per_page;
|
||||
$cutoff = time() - self::WINDOW_SECONDS;
|
||||
|
||||
$sql_count = $wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT um1.user_id)
|
||||
FROM {$wpdb->usermeta} um1
|
||||
INNER JOIN {$wpdb->usermeta} um2 ON um2.user_id = um1.user_id AND um2.meta_key = %s
|
||||
WHERE um1.meta_key = %s AND CAST(um1.meta_value AS UNSIGNED) >= %d",
|
||||
self::META_PREVIOUS,
|
||||
self::META_CHANGED_AT,
|
||||
$cutoff
|
||||
);
|
||||
$total = (int) $wpdb->get_var($sql_count);
|
||||
|
||||
$sql = $wpdb->prepare(
|
||||
"SELECT um1.user_id, um1.meta_value AS changed_at, um2.meta_value AS previous_email
|
||||
FROM {$wpdb->usermeta} um1
|
||||
INNER JOIN {$wpdb->usermeta} um2 ON um2.user_id = um1.user_id AND um2.meta_key = %s
|
||||
WHERE um1.meta_key = %s AND CAST(um1.meta_value AS UNSIGNED) >= %d
|
||||
ORDER BY CAST(um1.meta_value AS UNSIGNED) DESC
|
||||
LIMIT %d OFFSET %d",
|
||||
self::META_PREVIOUS,
|
||||
self::META_CHANGED_AT,
|
||||
$cutoff,
|
||||
$per_page,
|
||||
$offset
|
||||
);
|
||||
$rows = $wpdb->get_results($sql, ARRAY_A);
|
||||
|
||||
$items = [];
|
||||
foreach ((array) $rows as $r) {
|
||||
$uid = (int) $r['user_id'];
|
||||
$u = get_userdata($uid);
|
||||
if (!$u) continue;
|
||||
$items[] = [
|
||||
'user_id' => $uid,
|
||||
'login' => $u->user_login,
|
||||
'display_name' => $u->display_name,
|
||||
'current_email' => $u->user_email,
|
||||
'previous_email' => (string) $r['previous_email'],
|
||||
'changed_at' => (int) $r['changed_at'],
|
||||
'expires_in' => max(0, (int) $r['changed_at'] + self::WINDOW_SECONDS - time()),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行回滚。
|
||||
*
|
||||
* @return array{success:bool, message?:string, error?:string}
|
||||
*/
|
||||
public static function rollback(int $user_id, int $by_admin_id = 0): array {
|
||||
if ($user_id <= 0 || !current_user_can('edit_user', $user_id)) {
|
||||
return ['success' => false, 'error' => __('权限不足', 'aksrt')];
|
||||
}
|
||||
if (!self::is_within_window($user_id)) {
|
||||
return ['success' => false, 'error' => __('回滚窗口已过期或当前账号未发起过邮箱变更', 'aksrt')];
|
||||
}
|
||||
|
||||
$previous = (string) get_user_meta($user_id, self::META_PREVIOUS, true);
|
||||
if ($previous === '' || !is_email($previous)) {
|
||||
return ['success' => false, 'error' => __('旧邮箱无效', 'aksrt')];
|
||||
}
|
||||
|
||||
// 检查旧邮箱是否被其他用户占用(理论上极少见)
|
||||
$occupier = email_exists($previous);
|
||||
if ($occupier && (int) $occupier !== $user_id) {
|
||||
return ['success' => false, 'error' => __('旧邮箱已被其他账号占用,无法回滚', 'aksrt')];
|
||||
}
|
||||
|
||||
$update = wp_update_user([
|
||||
'ID' => $user_id,
|
||||
'user_email' => $previous,
|
||||
]);
|
||||
if (is_wp_error($update)) {
|
||||
return ['success' => false, 'error' => $update->get_error_message()];
|
||||
}
|
||||
|
||||
// 清空回滚 meta(一次性)
|
||||
delete_user_meta($user_id, self::META_PREVIOUS);
|
||||
delete_user_meta($user_id, self::META_CHANGED_AT);
|
||||
|
||||
// 记录管理员操作日志
|
||||
$log = get_user_meta($user_id, 'aksrt_email_verification_admin_log', true);
|
||||
if (!is_array($log)) $log = [];
|
||||
$log[] = [
|
||||
't' => time(),
|
||||
'action' => 'email_rollback',
|
||||
'by' => $by_admin_id ?: get_current_user_id(),
|
||||
];
|
||||
if (count($log) > 10) $log = array_slice($log, -10);
|
||||
update_user_meta($user_id, 'aksrt_email_verification_admin_log', $log);
|
||||
|
||||
if (class_exists('AKSRT\\Auth\\Service')) {
|
||||
Service::log_event('admin.email_rollback', [
|
||||
'uid' => $user_id,
|
||||
'by' => $by_admin_id ?: get_current_user_id(),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf(__('已将用户邮箱回滚为 %s', 'aksrt'), $previous),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 在用户列表行操作中加入"回滚邮箱"。
|
||||
*/
|
||||
public function add_row_action(array $actions, \WP_User $user): array {
|
||||
if (!current_user_can('edit_user', $user->ID)) return $actions;
|
||||
if (!self::is_within_window((int) $user->ID)) return $actions;
|
||||
|
||||
$url = wp_nonce_url(
|
||||
admin_url('admin-post.php?action=aksrt_email_rollback&user_id=' . (int) $user->ID),
|
||||
'aksrt_email_rollback_' . (int) $user->ID
|
||||
);
|
||||
$actions['aksrt_email_rollback'] = sprintf(
|
||||
'<a href="%s">%s</a>',
|
||||
esc_url($url),
|
||||
esc_html__('回滚邮箱', 'aksrt')
|
||||
);
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function handle_admin_rollback(): void {
|
||||
$user_id = isset($_GET['user_id']) ? absint($_GET['user_id']) : 0;
|
||||
if ($user_id <= 0 || !current_user_can('edit_user', $user_id)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
check_admin_referer('aksrt_email_rollback_' . $user_id);
|
||||
|
||||
$r = self::rollback($user_id, get_current_user_id());
|
||||
$type = !empty($r['success']) ? 'success' : 'error';
|
||||
$msg = $r['message'] ?? $r['error'] ?? '';
|
||||
set_transient('aksrt_admin_notice_' . get_current_user_id(), [['type' => $type, 'msg' => $msg]], 30);
|
||||
|
||||
wp_safe_redirect(wp_get_referer() ?: admin_url('users.php'));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class EmailVerification {
|
||||
|
||||
public static function user_requires_email_verification($user): bool {
|
||||
if (!$user instanceof \WP_User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!\AKSRT\Settings\Settings::registration_requires_email_verification()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !self::is_email_verified((int) $user->ID);
|
||||
}
|
||||
|
||||
public static function is_email_verified(int $user_id): bool {
|
||||
return (int) get_user_meta($user_id, Service::EMAIL_VERIFIED_META, true) === 1;
|
||||
}
|
||||
|
||||
public static function mark_email_verified(int $user_id): void {
|
||||
$was_verified = (int) get_user_meta($user_id, Service::EMAIL_VERIFIED_META, true) === 1;
|
||||
update_user_meta($user_id, Service::EMAIL_VERIFIED_META, 1);
|
||||
self::clear_email_verification($user_id);
|
||||
// 仅在状态由"未验证 → 已验证"跳变时触发,避免历史 backfill / 重复调用造成奖励重发。
|
||||
// 监听方负责自己幂等(aksrt_invite 表的 status 检查已经做到这一点)。
|
||||
if (!$was_verified) {
|
||||
do_action('aksrt_email_verified', $user_id);
|
||||
}
|
||||
}
|
||||
|
||||
public static function clear_email_verification(int $user_id): void {
|
||||
delete_user_meta($user_id, Service::EMAIL_METHOD_META);
|
||||
delete_user_meta($user_id, Service::EMAIL_TOKEN_HASH_META);
|
||||
delete_user_meta($user_id, Service::EMAIL_CODE_HASH_META);
|
||||
delete_user_meta($user_id, Service::EMAIL_EXPIRES_META);
|
||||
delete_user_meta($user_id, Service::EMAIL_REQUESTED_AT_META);
|
||||
delete_user_meta($user_id, Service::EMAIL_TARGET_META);
|
||||
delete_user_meta($user_id, Service::EMAIL_OBK_META);
|
||||
self::reset_email_code_attempt_trackers($user_id);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 公开 API:管理员后台 / 内部业务调用
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 是否已验证邮箱(公开版本,给后台 UI / 用户列表渲染用)。
|
||||
*/
|
||||
public static function user_is_email_verified(int $user_id): bool {
|
||||
return self::is_email_verified($user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一个用户当前的验证状态摘要,用于后台资料页展示。
|
||||
*
|
||||
* @return array{verified:bool, method:string, target_email:string, expires_at:int, requested_at:int, last_admin_log:array}
|
||||
*/
|
||||
public static function get_user_verification_state(int $user_id): array {
|
||||
return [
|
||||
'verified' => self::is_email_verified($user_id),
|
||||
'method' => (string) get_user_meta($user_id, Service::EMAIL_METHOD_META, true),
|
||||
'target_email' => (string) get_user_meta($user_id, Service::EMAIL_TARGET_META, true),
|
||||
'expires_at' => (int) get_user_meta($user_id, Service::EMAIL_EXPIRES_META, true),
|
||||
'requested_at' => (int) get_user_meta($user_id, Service::EMAIL_REQUESTED_AT_META, true),
|
||||
'last_admin_log' => (array) (get_user_meta($user_id, 'aksrt_email_verification_admin_log', true) ?: []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员:把指定用户标记为已验证(清除所有进行中的验证 meta)。
|
||||
*/
|
||||
public static function admin_mark_verified(int $user_id, int $by_admin_id = 0): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['success' => false, 'error' => __('无效的用户 ID', 'aksrt')];
|
||||
}
|
||||
if (!current_user_can('edit_user', $user_id)) {
|
||||
return ['success' => false, 'error' => __('权限不足', 'aksrt')];
|
||||
}
|
||||
self::mark_email_verified($user_id);
|
||||
self::record_admin_action($user_id, 'mark_verified', $by_admin_id);
|
||||
Service::log_event('admin.action', ['action' => 'mark_verified', 'uid' => $user_id, 'by' => $by_admin_id]);
|
||||
return ['success' => true, 'message' => __('已标记为已验证', 'aksrt')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员:撤销已验证状态(用于测试 / 回滚 / 应对邮箱泄漏)。
|
||||
*/
|
||||
public static function admin_unmark_verified(int $user_id, int $by_admin_id = 0): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['success' => false, 'error' => __('无效的用户 ID', 'aksrt')];
|
||||
}
|
||||
if (!current_user_can('edit_user', $user_id)) {
|
||||
return ['success' => false, 'error' => __('权限不足', 'aksrt')];
|
||||
}
|
||||
update_user_meta($user_id, Service::EMAIL_VERIFIED_META, 0);
|
||||
self::clear_email_verification($user_id);
|
||||
self::record_admin_action($user_id, 'unmark_verified', $by_admin_id);
|
||||
Service::log_event('admin.action', ['action' => 'unmark_verified', 'uid' => $user_id, 'by' => $by_admin_id]);
|
||||
return ['success' => true, 'message' => __('已撤销验证状态', 'aksrt')];
|
||||
}
|
||||
/**
|
||||
* 管理员:立即发送 / 重发验证邮件(绕过 60s 节流)。
|
||||
*/
|
||||
public static function admin_send_verification(int $user_id, int $by_admin_id = 0): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['success' => false, 'error' => __('无效的用户 ID', 'aksrt')];
|
||||
}
|
||||
if (!current_user_can('edit_user', $user_id)) {
|
||||
return ['success' => false, 'error' => __('权限不足', 'aksrt')];
|
||||
}
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user || is_wp_error($user)) {
|
||||
return ['success' => false, 'error' => __('用户不存在', 'aksrt')];
|
||||
}
|
||||
|
||||
$method = Service::current_registration_verification_method();
|
||||
$email = (string) get_user_meta($user_id, Service::EMAIL_TARGET_META, true);
|
||||
if (!in_array($method, ['link', 'code'], true)) {
|
||||
$method = \AKSRT\Settings\Settings::registration_verification_method();
|
||||
}
|
||||
if ($email === '' || !is_email($email)) {
|
||||
$email = (string) $user->user_email;
|
||||
}
|
||||
|
||||
$result = self::begin_email_verification($user, $method, [
|
||||
'email' => $email,
|
||||
'obk' => (string) get_user_meta($user_id, Service::EMAIL_OBK_META, true),
|
||||
'bypass_throttle' => true,
|
||||
'force' => true,
|
||||
]);
|
||||
|
||||
// 标记为待验证(确保 admin 主动发邮件后用户被视为未验证)
|
||||
if (!empty($result['success'])) {
|
||||
update_user_meta($user_id, Service::EMAIL_VERIFIED_META, 0);
|
||||
self::record_admin_action($user_id, 'send_verification', $by_admin_id);
|
||||
Service::log_event('admin.action', ['action' => 'send_verification', 'uid' => $user_id, 'by' => $by_admin_id]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把已存在的用户视为受信任(grandfather):写入 aksrt_email_verified=1。
|
||||
*
|
||||
* 仅写入 meta 不存在的用户;已经显式为 0 的(正在走验证流程的新注册)保持不动。
|
||||
*
|
||||
* 用户量 > 50000 时改为异步分批;否则内联完成。返回处理总数。
|
||||
*
|
||||
* @param bool $force 强制再次执行(即便 backfill_done 标记已存在),调试用
|
||||
*/
|
||||
public static function backfill_legacy_users_as_verified(bool $force = false): int {
|
||||
if (!$force && get_option('aksrt_email_verification_backfill_done')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
if (function_exists('count_users')) {
|
||||
$counts = count_users();
|
||||
$total = (int) ($counts['total_users'] ?? 0);
|
||||
}
|
||||
|
||||
// 大表保护:超过 5w 用户改为后台 cron 分批
|
||||
if ($total > 50000) {
|
||||
if (!wp_next_scheduled('aksrt_email_verification_backfill_batch')) {
|
||||
wp_schedule_single_event(time() + 30, 'aksrt_email_verification_backfill_batch', [0]);
|
||||
}
|
||||
update_option('aksrt_email_verification_backfill_log', [
|
||||
'mode' => 'async',
|
||||
'total' => $total,
|
||||
'scheduled' => time(),
|
||||
'by' => get_current_user_id(),
|
||||
]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 内联分批,每批 500
|
||||
$updated = 0;
|
||||
$paged = 1;
|
||||
do {
|
||||
$users = get_users([
|
||||
'number' => 500,
|
||||
'paged' => $paged,
|
||||
'fields' => ['ID'],
|
||||
'meta_query' => [
|
||||
[
|
||||
'key' => Service::EMAIL_VERIFIED_META,
|
||||
'compare' => 'NOT EXISTS',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
if (empty($users)) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($users as $u) {
|
||||
update_user_meta((int) $u->ID, Service::EMAIL_VERIFIED_META, 1);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$paged++;
|
||||
// 让 MySQL 喘口气
|
||||
if (function_exists('usleep')) {
|
||||
usleep(50000);
|
||||
}
|
||||
} while (count($users) === 500 && $paged < 200); // 上限 10w 兜底防呆
|
||||
|
||||
update_option('aksrt_email_verification_backfill_done', time());
|
||||
update_option('aksrt_email_verification_backfill_log', [
|
||||
'mode' => 'inline',
|
||||
'count' => $updated,
|
||||
'ran_at' => time(),
|
||||
'by' => get_current_user_id(),
|
||||
]);
|
||||
Service::log_event('admin.backfill', ['count' => $updated, 'by' => get_current_user_id()]);
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步分批回填:cron 钩子。每批 500,自动调度下一批直到完成。
|
||||
*/
|
||||
public static function run_async_backfill_batch(int $offset = 0): void {
|
||||
$users = get_users([
|
||||
'number' => 500,
|
||||
'offset' => max(0, $offset),
|
||||
'fields' => ['ID'],
|
||||
'orderby' => 'ID',
|
||||
'order' => 'ASC',
|
||||
'meta_query' => [
|
||||
[
|
||||
'key' => Service::EMAIL_VERIFIED_META,
|
||||
'compare' => 'NOT EXISTS',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
if (empty($users)) {
|
||||
update_option('aksrt_email_verification_backfill_done', time());
|
||||
$log = (array) get_option('aksrt_email_verification_backfill_log', []);
|
||||
$log['finished_at'] = time();
|
||||
update_option('aksrt_email_verification_backfill_log', $log);
|
||||
Service::log_event('admin.backfill.finished', []);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($users as $u) {
|
||||
update_user_meta((int) $u->ID, Service::EMAIL_VERIFIED_META, 1);
|
||||
}
|
||||
|
||||
// 调度下一批
|
||||
wp_schedule_single_event(time() + 30, 'aksrt_email_verification_backfill_batch', [$offset + 500]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录管理员操作日志(每个用户最多 10 条 ring buffer)。
|
||||
*/
|
||||
public static function record_admin_action(int $user_id, string $action, int $by_admin_id): void {
|
||||
$log = get_user_meta($user_id, 'aksrt_email_verification_admin_log', true);
|
||||
if (!is_array($log)) {
|
||||
$log = [];
|
||||
}
|
||||
$log[] = [
|
||||
't' => time(),
|
||||
'action' => $action,
|
||||
'by' => $by_admin_id ?: get_current_user_id(),
|
||||
];
|
||||
if (count($log) > 10) {
|
||||
$log = array_slice($log, -10);
|
||||
}
|
||||
update_user_meta($user_id, 'aksrt_email_verification_admin_log', $log);
|
||||
}
|
||||
// ============================================================
|
||||
// 验证码失败计数 / IP 节流
|
||||
// ============================================================
|
||||
|
||||
public static function email_code_ip_throttle_key(int $user_id): string {
|
||||
$ip = sanitize_text_field((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
if ($user_id <= 0 || $ip === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'aksrt_email_code_try_' . $user_id . '_' . md5($ip);
|
||||
}
|
||||
|
||||
public static function reset_email_code_attempt_trackers(int $user_id): void {
|
||||
delete_user_meta($user_id, Service::EMAIL_CODE_ATTEMPTS_META);
|
||||
$ip_key = self::email_code_ip_throttle_key($user_id);
|
||||
if ($ip_key !== '') {
|
||||
delete_transient($ip_key);
|
||||
}
|
||||
}
|
||||
|
||||
public static function fail_email_code_attempt(int $user_id): array {
|
||||
$attempts = (int) get_user_meta($user_id, Service::EMAIL_CODE_ATTEMPTS_META, true);
|
||||
$attempts++;
|
||||
update_user_meta($user_id, Service::EMAIL_CODE_ATTEMPTS_META, $attempts);
|
||||
|
||||
$ip_attempts = 0;
|
||||
$ip_key = self::email_code_ip_throttle_key($user_id);
|
||||
if ($ip_key !== '') {
|
||||
$ip_attempts = (int) get_transient($ip_key);
|
||||
$ip_attempts++;
|
||||
set_transient($ip_key, $ip_attempts, Service::EMAIL_CODE_IP_TTL);
|
||||
}
|
||||
|
||||
if ($attempts >= Service::EMAIL_CODE_MAX_ATTEMPTS || $ip_attempts >= Service::EMAIL_CODE_MAX_ATTEMPTS) {
|
||||
delete_user_meta($user_id, Service::EMAIL_CODE_HASH_META);
|
||||
update_user_meta($user_id, Service::EMAIL_REQUESTED_AT_META, 0);
|
||||
Service::log_event('verify.code.locked', ['uid' => $user_id, 'attempts' => $attempts]);
|
||||
return ['success' => false, 'error' => __('验证码错误次数过多,当前验证码已失效,请重新发送。', 'aksrt')];
|
||||
}
|
||||
|
||||
Service::log_event('verify.code.fail', ['uid' => $user_id, 'attempts' => $attempts]);
|
||||
return ['success' => false, 'error' => __('邮箱验证码错误或已过期,请重新输入。', 'aksrt')];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 验证流程:发起 / 校验 / 完成
|
||||
// ============================================================
|
||||
|
||||
public static function begin_email_verification(\WP_User $user, string $method, array $args = []): array {
|
||||
$method = in_array($method, ['link', 'code'], true) ? $method : 'link';
|
||||
$email = sanitize_email((string) ($args['email'] ?? $user->user_email));
|
||||
$obk = sanitize_text_field((string) ($args['obk'] ?? ''));
|
||||
$bypass_throttle = !empty($args['bypass_throttle']);
|
||||
if ($email === '' || !is_email($email)) {
|
||||
return ['success' => false, 'error' => __('当前账号缺少有效邮箱,无法发送验证邮件', 'aksrt')];
|
||||
}
|
||||
|
||||
// 节流:仅在非管理员路径生效(admin_send_verification 会传 bypass_throttle=true)
|
||||
if (!$bypass_throttle) {
|
||||
$last_requested = (int) get_user_meta($user->ID, Service::EMAIL_REQUESTED_AT_META, true);
|
||||
if ($last_requested > 0 && (time() - $last_requested) < 60 && empty($args['force'])) {
|
||||
return ['success' => false, 'error' => __('操作过于频繁,请稍后再试', 'aksrt')];
|
||||
}
|
||||
}
|
||||
|
||||
$expires_at = time() + (\AKSRT\Settings\Settings::registration_verification_ttl_minutes() * MINUTE_IN_SECONDS);
|
||||
update_user_meta($user->ID, Service::EMAIL_VERIFIED_META, 0);
|
||||
update_user_meta($user->ID, Service::EMAIL_METHOD_META, $method);
|
||||
update_user_meta($user->ID, Service::EMAIL_TARGET_META, $email);
|
||||
update_user_meta($user->ID, Service::EMAIL_EXPIRES_META, $expires_at);
|
||||
update_user_meta($user->ID, Service::EMAIL_REQUESTED_AT_META, time());
|
||||
if ($obk !== '') {
|
||||
update_user_meta($user->ID, Service::EMAIL_OBK_META, $obk);
|
||||
}
|
||||
|
||||
delete_user_meta($user->ID, Service::EMAIL_TOKEN_HASH_META);
|
||||
delete_user_meta($user->ID, Service::EMAIL_CODE_HASH_META);
|
||||
|
||||
if ($method === 'code') {
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
update_user_meta($user->ID, Service::EMAIL_CODE_HASH_META, wp_hash_password($code));
|
||||
// 新验证码 = 重置失败计数 / IP 节流,避免被旧计数立即锁死
|
||||
self::reset_email_code_attempt_trackers((int) $user->ID);
|
||||
$sent = self::send_registration_verification_code_email($user, $email, $code);
|
||||
} else {
|
||||
$token = wp_generate_password(48, false, false);
|
||||
update_user_meta($user->ID, Service::EMAIL_TOKEN_HASH_META, hash('sha256', $token));
|
||||
$sent = self::send_registration_verification_link_email($user, $email, $token);
|
||||
}
|
||||
|
||||
if (!$sent) {
|
||||
self::clear_email_verification((int) $user->ID);
|
||||
Service::log_event('email.send.fail', ['uid' => (int) $user->ID, 'method' => $method]);
|
||||
return ['success' => false, 'error' => __('验证邮件发送失败,请稍后重试', 'aksrt')];
|
||||
}
|
||||
|
||||
Service::log_event('email.send.ok', ['uid' => (int) $user->ID, 'method' => $method]);
|
||||
|
||||
$message = $method === 'code'
|
||||
? __('注册成功,验证码已发送到你的邮箱,请完成验证后继续。', 'aksrt')
|
||||
: __('注册成功,验证邮件已发送到你的邮箱,请点击邮件中的链接完成验证。', 'aksrt');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
public static function verify_email_token(int $user_id, string $token): array {
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user || is_wp_error($user)) {
|
||||
return ['success' => false, 'error' => __('验证账号不存在或已失效', 'aksrt')];
|
||||
}
|
||||
|
||||
$stored_hash = (string) get_user_meta($user_id, Service::EMAIL_TOKEN_HASH_META, true);
|
||||
$expires_at = (int) get_user_meta($user_id, Service::EMAIL_EXPIRES_META, true);
|
||||
if ($stored_hash === '' || $expires_at <= time() || !hash_equals($stored_hash, hash('sha256', $token))) {
|
||||
return ['success' => false, 'error' => __('验证链接无效或已过期,请重新发送验证邮件。', 'aksrt')];
|
||||
}
|
||||
|
||||
return self::complete_email_verification($user);
|
||||
}
|
||||
|
||||
public static function verify_email_code(int $user_id, string $code): array {
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user || is_wp_error($user)) {
|
||||
return ['success' => false, 'error' => __('验证账号不存在或已失效', 'aksrt')];
|
||||
}
|
||||
|
||||
// 先做 IP 节流:相同 IP 在 EMAIL_CODE_IP_TTL 内累计失败到上限即直接拒绝
|
||||
$ip_key = self::email_code_ip_throttle_key($user_id);
|
||||
if ($ip_key !== '') {
|
||||
$ip_attempts = (int) get_transient($ip_key);
|
||||
if ($ip_attempts >= Service::EMAIL_CODE_MAX_ATTEMPTS) {
|
||||
Service::log_event('verify.code.ip_throttled', ['uid' => $user_id, 'attempts' => $ip_attempts]);
|
||||
return ['success' => false, 'error' => __('操作过于频繁,请稍后再试', 'aksrt')];
|
||||
}
|
||||
}
|
||||
|
||||
// 用户级失败计数:达到上限后该验证码立即作废
|
||||
$user_attempts = (int) get_user_meta($user_id, Service::EMAIL_CODE_ATTEMPTS_META, true);
|
||||
if ($user_attempts >= Service::EMAIL_CODE_MAX_ATTEMPTS) {
|
||||
delete_user_meta($user_id, Service::EMAIL_CODE_HASH_META);
|
||||
update_user_meta($user_id, Service::EMAIL_REQUESTED_AT_META, 0);
|
||||
Service::log_event('verify.code.locked.read', ['uid' => $user_id]);
|
||||
return ['success' => false, 'error' => __('验证码错误次数过多,当前验证码已失效,请重新发送。', 'aksrt')];
|
||||
}
|
||||
|
||||
$stored_hash = (string) get_user_meta($user_id, Service::EMAIL_CODE_HASH_META, true);
|
||||
$expires_at = (int) get_user_meta($user_id, Service::EMAIL_EXPIRES_META, true);
|
||||
if ($stored_hash === '' || $expires_at <= time() || !wp_check_password($code, $stored_hash)) {
|
||||
return self::fail_email_code_attempt($user_id);
|
||||
}
|
||||
|
||||
self::reset_email_code_attempt_trackers($user_id);
|
||||
return self::complete_email_verification($user);
|
||||
}
|
||||
|
||||
public static function complete_email_verification(\WP_User $user): array {
|
||||
$user_id = (int) $user->ID;
|
||||
$obk = (string) get_user_meta($user_id, Service::EMAIL_OBK_META, true);
|
||||
$oauth = $obk !== '' ? Service::get_oauth_bind_context($obk) : null;
|
||||
$email = (string) get_user_meta($user_id, Service::EMAIL_TARGET_META, true);
|
||||
$was_verified = (int) get_user_meta($user_id, Service::EMAIL_VERIFIED_META, true) === 1;
|
||||
|
||||
update_user_meta($user_id, Service::EMAIL_VERIFIED_META, 1);
|
||||
self::clear_email_verification($user_id);
|
||||
Service::log_event('verify.success', ['uid' => $user_id]);
|
||||
// 状态跳变时触发 hook,邀请系统等模块据此发奖
|
||||
if (!$was_verified) {
|
||||
do_action('aksrt_email_verified', $user_id);
|
||||
}
|
||||
if ($email !== '' && is_email($email)) {
|
||||
self::send_registration_verified_notice_email($user, $email);
|
||||
}
|
||||
|
||||
if ($oauth && is_array($oauth)) {
|
||||
Service::finalize_oauth_registration($user_id, $oauth, $obk);
|
||||
} elseif (\AKSRT\Settings\Settings::registration_auto_login_after_verify()) {
|
||||
wp_set_current_user($user_id);
|
||||
wp_set_auth_cookie($user_id, true);
|
||||
}
|
||||
|
||||
$redirect = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/');
|
||||
if (!\AKSRT\Settings\Settings::registration_auto_login_after_verify()) {
|
||||
$redirect = aksrt_get_login_url();
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => __('邮箱验证成功,即将进入你的账号。', 'aksrt'),
|
||||
'redirect' => $redirect,
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 通知邮件
|
||||
// ============================================================
|
||||
|
||||
public static function send_registration_verification_link_email(\WP_User $user, string $email, string $token): bool {
|
||||
$url = Service::get_email_verification_url((int) $user->ID, ['token' => $token]);
|
||||
$site_name = get_bloginfo('name');
|
||||
$default_subject = '[' . $site_name . '] ' . __('验证你的邮箱地址', 'aksrt');
|
||||
$default_content = sprintf(
|
||||
__("您好 %s,\n\n欢迎注册 %s。\n\n请点击以下链接完成邮箱验证:\n\n%s\n\n验证成功后系统将自动为你登录。\n\n如果这不是你的操作,请忽略此邮件。", 'aksrt'),
|
||||
$user->user_login, $site_name, $url
|
||||
);
|
||||
|
||||
return \AKSRT\Settings\Settings::send_event_email(
|
||||
'reg_verify_link', $email, $default_subject, $default_content,
|
||||
['{username}' => $user->user_login, '{site_name}' => $site_name, '{url}' => $url]
|
||||
);
|
||||
}
|
||||
|
||||
public static function send_registration_verification_code_email(\WP_User $user, string $email, string $code): bool {
|
||||
$site_name = get_bloginfo('name');
|
||||
$default_subject = '[' . $site_name . '] ' . __('邮箱验证码', 'aksrt');
|
||||
$default_content = sprintf(
|
||||
__("您好 %s,\n\n欢迎注册 %s。\n\n你的邮箱验证码为:\n\n【%s】\n\n验证码验证成功后系统将自动为你登录。\n\n如果这不是你的操作,请忽略此邮件。", 'aksrt'),
|
||||
$user->user_login, $site_name, $code
|
||||
);
|
||||
|
||||
return \AKSRT\Settings\Settings::send_event_email(
|
||||
'reg_verify_code', $email, $default_subject, $default_content,
|
||||
['{username}' => $user->user_login, '{site_name}' => $site_name, '{code}' => $code]
|
||||
);
|
||||
}
|
||||
|
||||
public static function send_registration_verified_notice_email(\WP_User $user, string $email): bool {
|
||||
$site_name = get_bloginfo('name');
|
||||
$default_subject = '[' . $site_name . '] ' . __('邮箱验证成功', 'aksrt');
|
||||
$default_content = sprintf(
|
||||
__("您好 %s,\n\n你在 %s 的账号邮箱已经验证成功。\n\n如果这不是你的操作,请立即联系站点管理员。", 'aksrt'),
|
||||
$user->user_login, $site_name
|
||||
);
|
||||
|
||||
return \AKSRT\Settings\Settings::send_event_email(
|
||||
'reg_success', $email, $default_subject, $default_content,
|
||||
['{username}' => $user->user_login, '{site_name}' => $site_name]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Moderation {
|
||||
use ModerationPart1;
|
||||
use ModerationPart2;
|
||||
|
||||
|
||||
private static $instance = null;
|
||||
private $config = [];
|
||||
|
||||
/**
|
||||
* Runtime map: fingerprint => moderation decision payload.
|
||||
*
|
||||
* @var array<string, array<string, mixed>>
|
||||
*/
|
||||
private $moderation_result_map = [];
|
||||
|
||||
/**
|
||||
* Runtime map: fingerprint => forced comment_approved value.
|
||||
*
|
||||
* @var array<string, string|int>
|
||||
*/
|
||||
private $forced_approved_map = [];
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __construct() {
|
||||
$this->config = get_option('aksrt_moderation_config', []);
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_filter('preprocess_comment', [$this, 'process'], 5);
|
||||
add_filter('pre_comment_approved', [$this, 'enforce_pre_comment_approved'], 50, 2);
|
||||
add_action('comment_post', [$this, 'after_post'], 10, 3);
|
||||
|
||||
// Native WP Admin > Comments column.
|
||||
add_filter('manage_edit-comments_columns', [$this, 'register_comments_columns']);
|
||||
add_action('manage_comments_custom_column', [$this, 'render_comments_custom_column'], 10, 2);
|
||||
}
|
||||
|
||||
public function get_config(): array {
|
||||
$defaults = [
|
||||
'enabled' => false,
|
||||
'akismet_enabled' => false,
|
||||
'akismet_site_url' => '',
|
||||
'akismet_blog_lang' => 'zh-CN',
|
||||
'akismet_api_key' => '',
|
||||
'ai_enabled' => false,
|
||||
'ai_moderate_comments' => true,
|
||||
'ai_moderate_background' => true,
|
||||
'auto_approve_low_risk' => true,
|
||||
'auto_reject_high_risk' => true,
|
||||
'low_risk_max_score' => 35,
|
||||
'high_risk_min_score' => 80,
|
||||
'blocked_keywords' => '',
|
||||
'rate_limit_enabled' => false,
|
||||
'rate_limit_min_interval' => 8,
|
||||
'rate_limit_max_per_hour' => 15,
|
||||
'geoip_enabled' => false,
|
||||
'geoip_provider' => 'ipapi',
|
||||
'geoip_api_key' => '',
|
||||
'geoip_block_countries' => [],
|
||||
];
|
||||
return array_merge($defaults, $this->config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main moderation entry.
|
||||
*/
|
||||
public function process(array $commentdata): array {
|
||||
|
||||
// 人机验证:覆盖 REST 与原生 wp-comments-post 两条提交合同(与审核开关无关)
|
||||
if (function_exists('aksrt_require_captcha')) {
|
||||
$payload = is_array($_POST ?? null) ? wp_unslash($_POST) : [];
|
||||
if (isset($payload['captcha']) && is_array($payload['captcha'])) {
|
||||
$payload = array_merge($payload, $payload['captcha']);
|
||||
}
|
||||
$captcha_result = aksrt_require_captcha('comment', $payload);
|
||||
if ($captcha_result !== true) {
|
||||
$msg = is_string($captcha_result) ? $captcha_result : __('人机验证失败', 'aksrt');
|
||||
wp_die(
|
||||
esc_html($msg),
|
||||
esc_html__('评论提交失败', 'aksrt'),
|
||||
['response' => 400, 'back_link' => true]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$config = $this->get_config();
|
||||
|
||||
if (empty($config['enabled'])) {
|
||||
return $commentdata;
|
||||
}
|
||||
|
||||
$client_ip = $this->get_client_ip();
|
||||
$decision = $this->new_default_result();
|
||||
$decision['ip'] = $client_ip;
|
||||
|
||||
try {
|
||||
// 1) Rate limit.
|
||||
$rate_result = $this->check_rate_limit($commentdata, $config, $client_ip);
|
||||
if ($rate_result !== true) {
|
||||
$this->remember_result($commentdata, [
|
||||
'result' => 'blocked',
|
||||
'source' => 'rate_limit',
|
||||
'reason' => 'rate_limit_triggered',
|
||||
]);
|
||||
wp_die(
|
||||
'<p>' . esc_html($rate_result) . '</p>',
|
||||
__('Rate limit', 'aksrt'),
|
||||
['response' => 429]
|
||||
);
|
||||
}
|
||||
|
||||
// 2) GeoIP restriction.
|
||||
$geoip_result = $this->check_geoip($config, $client_ip);
|
||||
if ($geoip_result !== true) {
|
||||
$this->remember_result($commentdata, [
|
||||
'result' => 'blocked',
|
||||
'source' => 'geoip',
|
||||
'reason' => 'geoip_blocked',
|
||||
]);
|
||||
wp_die(
|
||||
'<p>' . esc_html($geoip_result) . '</p>',
|
||||
__('Geo restriction', 'aksrt'),
|
||||
['response' => 403]
|
||||
);
|
||||
}
|
||||
|
||||
// 3) Blocked keywords: force pending and write AI-like reason.
|
||||
$keyword = $this->check_blocked_keywords($commentdata, $config);
|
||||
$keyword_hit = !empty($keyword['hit']);
|
||||
if ($keyword_hit) {
|
||||
$commentdata['comment_approved'] = 0;
|
||||
$decision['result'] = 'pending';
|
||||
$decision['source'] = 'ai';
|
||||
$decision['reason'] = 'keyword_hit';
|
||||
$decision['ai_flagged'] = '1';
|
||||
$decision['ai_score'] = '100';
|
||||
$decision['ai_top_category'] = 'blocked_keyword';
|
||||
$decision['keyword'] = (string) ($keyword['keyword'] ?? '');
|
||||
$decision['keyword_match_type'] = (string) ($keyword['match_type'] ?? 'plain');
|
||||
}
|
||||
|
||||
// 4) Akismet.
|
||||
$akismet = $this->check_akismet($commentdata, $config, $client_ip);
|
||||
if ($akismet === 'spam') {
|
||||
$commentdata['comment_approved'] = 'spam';
|
||||
$decision['result'] = 'spam';
|
||||
$decision['source'] = 'akismet';
|
||||
$decision['reason'] = 'spam';
|
||||
$decision['akismet'] = 'spam';
|
||||
} elseif ($akismet === 'ham') {
|
||||
$decision['akismet'] = 'ham';
|
||||
if ($decision['source'] === 'wp_default') {
|
||||
$decision['source'] = 'akismet';
|
||||
$decision['reason'] = 'ham';
|
||||
}
|
||||
} elseif ($akismet === 'error') {
|
||||
$decision['akismet'] = 'error';
|
||||
} else {
|
||||
$decision['akismet'] = 'unknown';
|
||||
}
|
||||
|
||||
// 5) AI moderation provider (Baidu only).
|
||||
// If keyword already hit, keep keyword decision locked and skip AI override.
|
||||
if (!$keyword_hit) {
|
||||
$ai = $this->check_ai_moderation($commentdata, $config);
|
||||
if (is_array($ai)) {
|
||||
$score = (float) ($ai['score'] ?? 0);
|
||||
$flagged = !empty($ai['flagged']) ? '1' : '0';
|
||||
$top_category = (string) ($ai['top_category'] ?? '');
|
||||
|
||||
$decision['ai_score'] = (string) round($score, 2);
|
||||
$decision['ai_flagged'] = $flagged;
|
||||
$decision['ai_top_category'] = $top_category;
|
||||
|
||||
if (!empty($config['auto_reject_high_risk']) && $score >= (float) ($config['high_risk_min_score'] ?? 80)) {
|
||||
// High-risk comments are auto rejected into spam list.
|
||||
$commentdata['comment_approved'] = 'spam';
|
||||
$decision['result'] = 'spam';
|
||||
$decision['source'] = 'ai';
|
||||
$decision['reason'] = 'ai_high_risk_auto_reject';
|
||||
} elseif (!empty($config['auto_approve_low_risk']) && $score <= (float) ($config['low_risk_max_score'] ?? 35)) {
|
||||
// Do not override stricter decision.
|
||||
if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') {
|
||||
$commentdata['comment_approved'] = 1;
|
||||
$decision['result'] = 'approved';
|
||||
$decision['source'] = 'ai';
|
||||
$decision['reason'] = 'ai_low_risk_auto_approve';
|
||||
}
|
||||
} else {
|
||||
if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') {
|
||||
$commentdata['comment_approved'] = 0;
|
||||
$decision['result'] = 'pending';
|
||||
$decision['source'] = 'ai';
|
||||
$decision['reason'] = 'ai_medium_risk_pending';
|
||||
}
|
||||
}
|
||||
} elseif ($ai === 'rejected') {
|
||||
$commentdata['comment_approved'] = 'spam';
|
||||
$decision['result'] = 'spam';
|
||||
$decision['source'] = 'ai';
|
||||
$decision['reason'] = 'ai_rejected_auto_reject';
|
||||
} elseif ($ai === 'error') {
|
||||
// Fail-safe: AI enabled but response is invalid/unusable, do not auto approve.
|
||||
if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') {
|
||||
$commentdata['comment_approved'] = 0;
|
||||
$decision['result'] = 'pending';
|
||||
}
|
||||
$decision['source'] = 'ai';
|
||||
$decision['reason'] = 'ai_response_invalid_pending';
|
||||
$decision['ai_flagged'] = '1';
|
||||
}
|
||||
}
|
||||
|
||||
$decision = $this->enrich_moderation_result($decision, $commentdata);
|
||||
$this->remember_result($commentdata, $decision);
|
||||
} catch (\Exception $e) {
|
||||
// Intentionally silent: keep moderation flow without writing runtime logs.
|
||||
}
|
||||
|
||||
return $commentdata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep pre_comment_approved in sync with preprocess_comment outcome.
|
||||
*/
|
||||
public function enforce_pre_comment_approved($approved, array $commentdata) {
|
||||
if (!is_array($commentdata)) {
|
||||
return $approved;
|
||||
}
|
||||
|
||||
$fingerprint = $this->comment_fingerprint($commentdata);
|
||||
if ($fingerprint !== '' && array_key_exists($fingerprint, $this->forced_approved_map)) {
|
||||
$forced = $this->forced_approved_map[$fingerprint];
|
||||
return $forced;
|
||||
}
|
||||
|
||||
if (array_key_exists('comment_approved', $commentdata)) {
|
||||
return $commentdata['comment_approved'];
|
||||
}
|
||||
|
||||
return $approved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist moderation details to comment meta.
|
||||
*/
|
||||
public function after_post(int $comment_id, $comment_approved, array $commentdata = []): void {
|
||||
$comment = get_comment($comment_id);
|
||||
if (!$comment instanceof \WP_Comment) {
|
||||
return;
|
||||
}
|
||||
|
||||
$decision = $this->resolve_result_from_runtime_or_default($commentdata, $comment);
|
||||
$decision = $this->enrich_moderation_result($decision, (array) $comment);
|
||||
|
||||
update_comment_meta($comment_id, 'aksrt_moderation_result', (string) $decision['result']);
|
||||
update_comment_meta($comment_id, 'aksrt_moderation_source', (string) $decision['source']);
|
||||
update_comment_meta($comment_id, 'aksrt_moderation_reason', (string) $decision['reason']);
|
||||
update_comment_meta($comment_id, 'aksrt_moderation_akismet', (string) $decision['akismet']);
|
||||
update_comment_meta($comment_id, 'aksrt_moderation_ai_score', (string) $decision['ai_score']);
|
||||
update_comment_meta($comment_id, 'aksrt_moderation_ai_flagged', (string) $decision['ai_flagged']);
|
||||
update_comment_meta($comment_id, 'aksrt_moderation_ai_top_category', (string) $decision['ai_top_category']);
|
||||
update_comment_meta($comment_id, 'aksrt_moderation_keyword', (string) $decision['keyword']);
|
||||
update_comment_meta($comment_id, 'aksrt_moderation_keyword_match_type', (string) $decision['keyword_match_type']);
|
||||
|
||||
$moderation_user_id = isset($comment->user_id) ? (int) $comment->user_id : 0;
|
||||
$moderation_user_ip = trim((string) ($comment->comment_author_IP ?? ''));
|
||||
if ($moderation_user_ip === '') {
|
||||
$moderation_user_ip = trim((string) ($decision['ip'] ?? ''));
|
||||
}
|
||||
if ($moderation_user_ip === '') {
|
||||
$moderation_user_ip = trim((string) ($commentdata['comment_author_IP'] ?? ($commentdata['comment_author_ip'] ?? '')));
|
||||
}
|
||||
|
||||
update_comment_meta($comment_id, 'aksrt_moderation_user_id', (string) $moderation_user_id);
|
||||
update_comment_meta($comment_id, 'aksrt_moderation_user_ip', $moderation_user_ip);
|
||||
|
||||
// Convenience marker for external tools expecting Akismet style keys.
|
||||
if ($decision['source'] === 'akismet' && $decision['akismet'] === 'spam') {
|
||||
update_comment_meta($comment_id, 'akismet_result', 'true');
|
||||
} elseif ($decision['source'] === 'akismet' && $decision['akismet'] === 'ham') {
|
||||
update_comment_meta($comment_id, 'akismet_result', 'false');
|
||||
}
|
||||
|
||||
// Clean runtime maps.
|
||||
$fp_comment = $this->comment_fingerprint((array) $comment);
|
||||
$fp_data = $this->comment_fingerprint($commentdata);
|
||||
foreach (array_unique(array_filter([$fp_comment, $fp_data])) as $fp) {
|
||||
unset($this->moderation_result_map[$fp], $this->forced_approved_map[$fp]);
|
||||
}
|
||||
}
|
||||
|
||||
public function register_comments_columns(array $columns): array {
|
||||
$new = [];
|
||||
foreach ($columns as $key => $label) {
|
||||
$new[$key] = $label;
|
||||
if ($key === 'comment') {
|
||||
$new['aksrt_moderation'] = __('Moderation', 'aksrt');
|
||||
}
|
||||
}
|
||||
if (!isset($new['aksrt_moderation'])) {
|
||||
$new['aksrt_moderation'] = __('Moderation', 'aksrt');
|
||||
}
|
||||
return $new;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ModerationPart1 {
|
||||
|
||||
public function render_comments_custom_column(string $column, int $comment_id): void {
|
||||
if ($column !== 'aksrt_moderation') {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = '';
|
||||
if (function_exists('get_comment')) {
|
||||
$comment_obj = get_comment($comment_id);
|
||||
if (is_object($comment_obj) && isset($comment_obj->comment_approved)) {
|
||||
$status = (string) $comment_obj->comment_approved;
|
||||
}
|
||||
}
|
||||
$status_label = $this->status_label($status);
|
||||
|
||||
$source = (string) get_comment_meta($comment_id, 'aksrt_moderation_source', true);
|
||||
$result_value = (string) get_comment_meta($comment_id, 'aksrt_moderation_result', true);
|
||||
$reason = (string) get_comment_meta($comment_id, 'aksrt_moderation_reason', true);
|
||||
$akismet = (string) get_comment_meta($comment_id, 'aksrt_moderation_akismet', true);
|
||||
$ai_score = (string) get_comment_meta($comment_id, 'aksrt_moderation_ai_score', true);
|
||||
$ai_flagged = (string) get_comment_meta($comment_id, 'aksrt_moderation_ai_flagged', true);
|
||||
$ai_top = (string) get_comment_meta($comment_id, 'aksrt_moderation_ai_top_category', true);
|
||||
$keyword = (string) get_comment_meta($comment_id, 'aksrt_moderation_keyword', true);
|
||||
$match_type = (string) get_comment_meta($comment_id, 'aksrt_moderation_keyword_match_type', true);
|
||||
$moderation_user_id = (string) get_comment_meta($comment_id, 'aksrt_moderation_user_id', true);
|
||||
$moderation_user_ip = (string) get_comment_meta($comment_id, 'aksrt_moderation_user_ip', true);
|
||||
|
||||
if ($source === '') {
|
||||
$source = 'wp_default';
|
||||
}
|
||||
if ($result_value === '') {
|
||||
$result_value = $this->result_by_approved($status);
|
||||
}
|
||||
if ($reason === '') {
|
||||
$reason = 'wp_default_policy';
|
||||
}
|
||||
if ($akismet === '') {
|
||||
$akismet_meta = get_comment_meta($comment_id, 'akismet_result', true);
|
||||
if ((string) $akismet_meta === 'true') {
|
||||
$akismet = 'spam';
|
||||
} elseif ((string) $akismet_meta === 'false') {
|
||||
$akismet = 'ham';
|
||||
} else {
|
||||
$akismet = 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
if ($source === 'wp_default' && $akismet === 'ham') {
|
||||
$source = 'akismet';
|
||||
if ($result_value === 'wp_default_policy') {
|
||||
$result_value = 'approved';
|
||||
}
|
||||
if ($reason === 'wp_default_policy') {
|
||||
$reason = 'ham';
|
||||
}
|
||||
} elseif ($source === 'wp_default' && $akismet === 'spam') {
|
||||
$source = 'akismet';
|
||||
$result_value = 'spam';
|
||||
if ($reason === 'wp_default_policy') {
|
||||
$reason = 'spam';
|
||||
}
|
||||
}
|
||||
|
||||
echo '<strong>' . esc_html($status_label) . '</strong><br>';
|
||||
echo 'Source: ' . esc_html($source) . ' | ';
|
||||
echo 'Result: ' . esc_html($result_value) . ' | ';
|
||||
echo 'Reason: ' . esc_html($reason) . ' | ';
|
||||
echo 'Akismet: ' . esc_html($akismet);
|
||||
|
||||
$extras = [];
|
||||
if ($ai_score !== '') {
|
||||
$extras[] = 'AI score: ' . $ai_score;
|
||||
}
|
||||
if ($ai_flagged !== '') {
|
||||
$extras[] = 'AI flagged: ' . $ai_flagged;
|
||||
}
|
||||
if ($ai_top !== '') {
|
||||
$extras[] = 'AI top: ' . $ai_top;
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$extras[] = 'Keyword: ' . $keyword;
|
||||
}
|
||||
if ($match_type !== '') {
|
||||
$extras[] = 'Match: ' . $match_type;
|
||||
}
|
||||
if ($moderation_user_id !== '') {
|
||||
$extras[] = 'UID: ' . $moderation_user_id;
|
||||
}
|
||||
if ($moderation_user_ip !== '') {
|
||||
$extras[] = 'IP: ' . $moderation_user_ip;
|
||||
}
|
||||
if (!empty($extras)) {
|
||||
echo '<br><small>' . esc_html(implode(' | ', $extras)) . '</small>';
|
||||
}
|
||||
}
|
||||
|
||||
private function new_default_result(): array {
|
||||
return [
|
||||
'result' => 'approved',
|
||||
'source' => 'wp_default',
|
||||
'reason' => 'wp_default_policy',
|
||||
'akismet' => 'unknown',
|
||||
'ai_score' => '',
|
||||
'ai_flagged' => '',
|
||||
'ai_top_category' => '',
|
||||
'keyword' => '',
|
||||
'keyword_match_type' => '',
|
||||
'ip' => '',
|
||||
];
|
||||
}
|
||||
|
||||
private function remember_result(array $commentdata, array $decision): void {
|
||||
$fp = $this->comment_fingerprint($commentdata);
|
||||
if ($fp === '') {
|
||||
return;
|
||||
}
|
||||
$this->moderation_result_map[$fp] = $decision;
|
||||
if (array_key_exists('comment_approved', $commentdata)) {
|
||||
$this->forced_approved_map[$fp] = $commentdata['comment_approved'];
|
||||
}
|
||||
}
|
||||
|
||||
private function resolve_result_from_runtime_or_default(array $commentdata, \WP_Comment $comment): array {
|
||||
$fp_data = $this->comment_fingerprint($commentdata);
|
||||
if ($fp_data !== '' && isset($this->moderation_result_map[$fp_data])) {
|
||||
return $this->moderation_result_map[$fp_data];
|
||||
}
|
||||
|
||||
$fp_comment = $this->comment_fingerprint((array) $comment);
|
||||
if ($fp_comment !== '' && isset($this->moderation_result_map[$fp_comment])) {
|
||||
return $this->moderation_result_map[$fp_comment];
|
||||
}
|
||||
|
||||
$fallback = $this->new_default_result();
|
||||
$fallback['result'] = $this->result_by_approved((string) $comment->comment_approved);
|
||||
$fallback['reason'] = 'wp_default_policy';
|
||||
|
||||
$akismet_meta = get_comment_meta($comment->comment_ID, 'akismet_result', true);
|
||||
if ((string) $akismet_meta === 'true') {
|
||||
$fallback['source'] = 'akismet';
|
||||
$fallback['reason'] = 'spam';
|
||||
$fallback['akismet'] = 'spam';
|
||||
$fallback['result'] = 'spam';
|
||||
} elseif ((string) $akismet_meta === 'false') {
|
||||
$fallback['source'] = 'akismet';
|
||||
$fallback['reason'] = 'ham';
|
||||
$fallback['akismet'] = 'ham';
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function enrich_moderation_result(array $result, array $commentdata): array {
|
||||
if (!isset($result['result']) || $result['result'] === '') {
|
||||
$approved = isset($commentdata['comment_approved']) ? (string) $commentdata['comment_approved'] : '1';
|
||||
$result['result'] = $this->result_by_approved($approved);
|
||||
}
|
||||
if (!isset($result['source']) || $result['source'] === '') {
|
||||
$result['source'] = 'wp_default';
|
||||
}
|
||||
if (!isset($result['reason']) || $result['reason'] === '') {
|
||||
$result['reason'] = 'wp_default_policy';
|
||||
}
|
||||
if (!isset($result['akismet']) || $result['akismet'] === '') {
|
||||
$result['akismet'] = 'unknown';
|
||||
}
|
||||
if (!isset($result['ai_score'])) {
|
||||
$result['ai_score'] = '';
|
||||
}
|
||||
if (!isset($result['ai_flagged'])) {
|
||||
$result['ai_flagged'] = '';
|
||||
}
|
||||
if (!isset($result['ai_top_category'])) {
|
||||
$result['ai_top_category'] = '';
|
||||
}
|
||||
if (!isset($result['keyword'])) {
|
||||
$result['keyword'] = '';
|
||||
}
|
||||
if (!isset($result['keyword_match_type'])) {
|
||||
$result['keyword_match_type'] = '';
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function result_by_approved(string $approved): string {
|
||||
if ($approved === 'spam') {
|
||||
return 'spam';
|
||||
}
|
||||
if ($approved === '0') {
|
||||
return 'pending';
|
||||
}
|
||||
if ($approved === 'trash') {
|
||||
return 'trash';
|
||||
}
|
||||
return 'approved';
|
||||
}
|
||||
|
||||
private function status_label(string $approved): string {
|
||||
if ($approved === '1') {
|
||||
return 'Approved';
|
||||
}
|
||||
if ($approved === '0') {
|
||||
return 'Pending';
|
||||
}
|
||||
if ($approved === 'spam') {
|
||||
return 'Spam';
|
||||
}
|
||||
if ($approved === 'trash') {
|
||||
return 'Trash';
|
||||
}
|
||||
return ucfirst($approved);
|
||||
}
|
||||
|
||||
private function comment_fingerprint(array $data): string {
|
||||
$post_id = (string) ($data['comment_post_ID'] ?? '');
|
||||
$author = strtolower(trim((string) ($data['comment_author'] ?? '')));
|
||||
$email = strtolower(trim((string) ($data['comment_author_email'] ?? '')));
|
||||
$content_md5 = md5(trim((string) ($data['comment_content'] ?? '')));
|
||||
$ip = trim((string) ($data['comment_author_IP'] ?? ($data['comment_author_ip'] ?? '')));
|
||||
|
||||
if ($post_id === '' && $author === '' && $email === '' && $content_md5 === md5('')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return md5(implode('|', [$post_id, $author, $email, $content_md5, $ip]));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1. Akismet
|
||||
// ============================================================
|
||||
|
||||
private function check_akismet(array $data, array $config, string $ip) {
|
||||
if (empty($config['akismet_enabled'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$api_key = trim((string) ($config['akismet_api_key'] ?? ''));
|
||||
if ($api_key === '') {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
$site_url = !empty($config['akismet_site_url']) ? (string) $config['akismet_site_url'] : home_url('/');
|
||||
|
||||
$payload = [
|
||||
'blog' => $site_url,
|
||||
'blog_lang' => $config['akismet_blog_lang'] ?? 'zh-CN',
|
||||
'blog_charset' => get_bloginfo('charset') ?: 'UTF-8',
|
||||
'user_ip' => $ip,
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'referrer' => $_SERVER['HTTP_REFERER'] ?? '',
|
||||
'comment_type' => 'comment',
|
||||
'comment_author' => $data['comment_author'] ?? '',
|
||||
'comment_author_email' => $data['comment_author_email'] ?? '',
|
||||
'comment_author_url' => $data['comment_author_URL'] ?? ($data['comment_author_url'] ?? ''),
|
||||
'comment_content' => $data['comment_content'] ?? '',
|
||||
'is_test' => defined('WP_DEBUG') && WP_DEBUG ? '1' : '0',
|
||||
];
|
||||
|
||||
$endpoint = sprintf('https://%s.rest.akismet.com/1.1/comment-check', $api_key);
|
||||
$response = wp_remote_post($endpoint, [
|
||||
'body' => http_build_query($payload),
|
||||
'timeout' => 10,
|
||||
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
$body = trim((string) wp_remote_retrieve_body($response));
|
||||
if ($body === 'true') {
|
||||
return 'spam';
|
||||
}
|
||||
if ($body === 'false') {
|
||||
return 'ham';
|
||||
}
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. AI Moderation
|
||||
// ============================================================
|
||||
|
||||
private function check_ai_moderation(array $data, array $config) {
|
||||
if (empty($config['ai_enabled']) || empty($config['ai_moderate_comments'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$content = trim((string) ($data['comment_content'] ?? ''));
|
||||
if ($content === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 委托给统一内容审核服务
|
||||
if (!class_exists('\AKSRT\Extensions\ContentAudit')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$result = \AKSRT\Extensions\ContentAudit::get_instance()->audit_text($content);
|
||||
if ($result['passed']) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 映射到审核风控的 score 模型
|
||||
$score = 70;
|
||||
if (in_array('non_compliant', $result['labels'] ?? [], true)) {
|
||||
$score = 95;
|
||||
}
|
||||
|
||||
return [
|
||||
'flagged' => 1,
|
||||
'score' => $score,
|
||||
'top_category' => $result['reason'] ?? 'content_audit_flagged',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片审核 — 委托给统一内容审核服务。
|
||||
*
|
||||
* @param string $image_path Absolute path to the local image file.
|
||||
* @param array $config Optional config override (merged with get_config()).
|
||||
* @return array ['passed' => bool, 'reason' => string, ...]
|
||||
*/
|
||||
public function baidu_moderate_image(string $image_path, array $config = []): array {
|
||||
$config = array_merge($this->get_config(), $config);
|
||||
|
||||
if (empty($config['ai_enabled']) || empty($config['ai_moderate_background'])) {
|
||||
return ['passed' => true, 'reason' => 'disabled'];
|
||||
}
|
||||
|
||||
if (!class_exists('\AKSRT\Extensions\ContentAudit')) {
|
||||
return ['passed' => true, 'reason' => 'audit_service_missing'];
|
||||
}
|
||||
|
||||
return \AKSRT\Extensions\ContentAudit::get_instance()->audit_image($image_path);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. Blocked keywords
|
||||
// ============================================================
|
||||
|
||||
private function check_blocked_keywords(array $data, array $config): array {
|
||||
$keywords_raw = (string) ($config['blocked_keywords'] ?? '');
|
||||
if (trim($keywords_raw) === '') {
|
||||
return ['hit' => false, 'keyword' => '', 'match_type' => ''];
|
||||
}
|
||||
|
||||
$parts = preg_split('/[\r\n,;、\x{FF0C}\x{FF1B}]+/u', $keywords_raw);
|
||||
$keywords = array_values(array_filter(array_map('trim', (array) $parts), static function ($v) {
|
||||
return $v !== '';
|
||||
}));
|
||||
if (empty($keywords)) {
|
||||
return ['hit' => false, 'keyword' => '', 'match_type' => ''];
|
||||
}
|
||||
|
||||
$text = implode(' ', [
|
||||
(string) ($data['comment_content'] ?? ''),
|
||||
(string) ($data['comment_author'] ?? ''),
|
||||
(string) ($data['comment_author_email'] ?? ''),
|
||||
(string) ($data['comment_author_URL'] ?? ($data['comment_author_url'] ?? '')),
|
||||
]);
|
||||
$text_norm = $this->normalize_text($text);
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
if ($keyword === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// User-defined regex keyword like /pattern/flags.
|
||||
if (preg_match('#^/.+/[imsxuADSUXJ]*$#', $keyword)) {
|
||||
$matched = @preg_match($keyword, $text);
|
||||
if ($matched === 1) {
|
||||
return ['hit' => true, 'keyword' => $keyword, 'match_type' => 'regex'];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->contains_text($text, $keyword)) {
|
||||
return ['hit' => true, 'keyword' => $keyword, 'match_type' => 'plain'];
|
||||
}
|
||||
|
||||
$keyword_norm = $this->normalize_text($keyword);
|
||||
if ($keyword_norm !== '' && $this->contains_text($text_norm, $keyword_norm)) {
|
||||
return ['hit' => true, 'keyword' => $keyword, 'match_type' => 'normalized'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['hit' => false, 'keyword' => '', 'match_type' => ''];
|
||||
}
|
||||
|
||||
private function contains_text(string $haystack, string $needle): bool {
|
||||
if ($needle === '') {
|
||||
return false;
|
||||
}
|
||||
if (function_exists('mb_stripos')) {
|
||||
return mb_stripos($haystack, $needle, 0, 'UTF-8') !== false;
|
||||
}
|
||||
return stripos($haystack, $needle) !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ModerationPart2 {
|
||||
|
||||
|
||||
private function normalize_text(string $text): string {
|
||||
$text = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $text);
|
||||
$text = preg_replace('/\s+/u', '', (string) $text);
|
||||
if (function_exists('mb_strtolower')) {
|
||||
return mb_strtolower((string) $text, 'UTF-8');
|
||||
}
|
||||
return strtolower((string) $text);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. Rate limit
|
||||
// ============================================================
|
||||
|
||||
private function check_rate_limit(array $data, array $config, string $ip) {
|
||||
if (empty($config['rate_limit_enabled'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$min_interval = max(1, (int) ($config['rate_limit_min_interval'] ?? 8));
|
||||
$max_per_hour = max(1, (int) ($config['rate_limit_max_per_hour'] ?? 15));
|
||||
$prefix = 'aksrt_rate_';
|
||||
|
||||
$last_key = $prefix . md5($ip . '_last');
|
||||
$hour_key = $prefix . md5($ip . '_hourly');
|
||||
$exp_key = '_' . $prefix . md5($ip . '_hourly_expiry');
|
||||
|
||||
$last_comment_time = get_transient($last_key);
|
||||
if ($last_comment_time !== false) {
|
||||
$elapsed = time() - (int) $last_comment_time;
|
||||
if ($elapsed < $min_interval) {
|
||||
$wait_time = $min_interval - $elapsed;
|
||||
return sprintf(__('You are commenting too fast. Please wait %d seconds.', 'aksrt'), $wait_time);
|
||||
}
|
||||
}
|
||||
|
||||
$hourly_count = get_transient($hour_key);
|
||||
if ($hourly_count !== false && (int) $hourly_count >= $max_per_hour) {
|
||||
$reset_time = (int) (get_transient($exp_key) ?: time());
|
||||
$remaining = max(0, $reset_time - time());
|
||||
$minutes = (int) ceil($remaining / 60);
|
||||
return sprintf(__('You have reached the hourly comment limit. Try again in %d minutes.', 'aksrt'), $minutes);
|
||||
}
|
||||
|
||||
if ($hourly_count === false) {
|
||||
set_transient($hour_key, 1, HOUR_IN_SECONDS);
|
||||
set_transient($exp_key, time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS);
|
||||
} else {
|
||||
set_transient($hour_key, ((int) $hourly_count) + 1, HOUR_IN_SECONDS);
|
||||
}
|
||||
set_transient($last_key, time(), $min_interval);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 5. GeoIP
|
||||
// ============================================================
|
||||
|
||||
private function check_geoip(array $config, string $ip) {
|
||||
if (empty($config['geoip_enabled'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$block_countries = $config['geoip_block_countries'] ?? [];
|
||||
if (!is_array($block_countries) || empty($block_countries)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$country_code = $this->lookup_geoip($ip, $config);
|
||||
if ($country_code === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$country_code = strtoupper($country_code);
|
||||
$block_upper = array_map('strtoupper', $block_countries);
|
||||
if (in_array($country_code, $block_upper, true)) {
|
||||
return sprintf(__('Comments from your region (%s) are currently blocked.', 'aksrt'), esc_html($country_code));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function lookup_geoip(string $ip, array $config): ?string {
|
||||
// Dedicated ISO country-code path for block lists (CN/US/…).
|
||||
// IPGeo returns localized names ("中国") which are not ISO codes.
|
||||
$provider = sanitize_key((string) ($config['geoip_provider'] ?? 'ipapi'));
|
||||
$api_key = (string) ($config['geoip_api_key'] ?? '');
|
||||
|
||||
if ($provider === 'ipinfo' || $provider === 'ipinfo.io') {
|
||||
$code = $this->geoip_ipinfo($ip, $api_key);
|
||||
if ($code !== null) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
$code = $this->geoip_ipapi($ip);
|
||||
if ($code !== null) {
|
||||
return $code;
|
||||
}
|
||||
|
||||
// Last resort: map IPGeo country label → ISO when possible.
|
||||
if (class_exists('\AKSRT\Extensions\IPGeo')) {
|
||||
$result = \AKSRT\Extensions\IPGeo::get_instance()->lookup($ip);
|
||||
$label = trim((string) ($result['country'] ?? ''));
|
||||
if ($label !== '') {
|
||||
return $this->country_label_to_iso($label);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map common country labels / ISO aliases to ISO-3166 alpha-2.
|
||||
*/
|
||||
private function country_label_to_iso(string $label): ?string {
|
||||
$raw = trim($label);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
if (preg_match('/^[A-Za-z]{2}$/', $raw)) {
|
||||
return strtoupper($raw);
|
||||
}
|
||||
$map = [
|
||||
'中国' => 'CN', '中华人民共和国' => 'CN', 'china' => 'CN', 'cn' => 'CN',
|
||||
'美国' => 'US', '美利坚合众国' => 'US', 'united states' => 'US', 'usa' => 'US', 'us' => 'US',
|
||||
'日本' => 'JP', 'japan' => 'JP', 'jp' => 'JP',
|
||||
'韩国' => 'KR', '大韩民国' => 'KR', 'south korea' => 'KR', 'korea' => 'KR', 'kr' => 'KR',
|
||||
'香港' => 'HK', '中国香港' => 'HK', 'hong kong' => 'HK', 'hk' => 'HK',
|
||||
'澳门' => 'MO', '中国澳门' => 'MO', 'macao' => 'MO', 'macau' => 'MO', 'mo' => 'MO',
|
||||
'台湾' => 'TW', '中国台湾' => 'TW', 'taiwan' => 'TW', 'tw' => 'TW',
|
||||
'新加坡' => 'SG', 'singapore' => 'SG', 'sg' => 'SG',
|
||||
'英国' => 'GB', 'united kingdom' => 'GB', 'uk' => 'GB', 'gb' => 'GB',
|
||||
'德国' => 'DE', 'germany' => 'DE', 'de' => 'DE',
|
||||
'法国' => 'FR', 'france' => 'FR', 'fr' => 'FR',
|
||||
'俄罗斯' => 'RU', 'russia' => 'RU', 'ru' => 'RU',
|
||||
'澳大利亚' => 'AU', 'australia' => 'AU', 'au' => 'AU',
|
||||
'加拿大' => 'CA', 'canada' => 'CA', 'ca' => 'CA',
|
||||
'印度' => 'IN', 'india' => 'IN', 'in' => 'IN',
|
||||
'越南' => 'VN', 'vietnam' => 'VN', 'vn' => 'VN',
|
||||
'泰国' => 'TH', 'thailand' => 'TH', 'th' => 'TH',
|
||||
'马来西亚' => 'MY', 'malaysia' => 'MY', 'my' => 'MY',
|
||||
'菲律宾' => 'PH', 'philippines' => 'PH', 'ph' => 'PH',
|
||||
'印度尼西亚' => 'ID', 'indonesia' => 'ID', 'id' => 'ID',
|
||||
];
|
||||
$key = function_exists('mb_strtolower') ? mb_strtolower($raw, 'UTF-8') : strtolower($raw);
|
||||
if (isset($map[$raw])) {
|
||||
return $map[$raw];
|
||||
}
|
||||
if (isset($map[$key])) {
|
||||
return $map[$key];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free ip-api.com endpoint.
|
||||
*/
|
||||
private function geoip_ipapi(string $ip): ?string {
|
||||
$url = 'http://ip-api.com/json/' . urlencode($ip) . '?fields=status,countryCode,message';
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => 5,
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
if (($body['status'] ?? '') === 'success') {
|
||||
return $body['countryCode'] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function geoip_ipinfo(string $ip, string $api_key): ?string {
|
||||
$url = 'https://ipinfo.io/' . urlencode($ip) . '/json?token=' . urlencode($api_key ?: '');
|
||||
$response = wp_remote_get($url, [
|
||||
'timeout' => 5,
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
return $body['country'] ?? null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Utilities
|
||||
// ============================================================
|
||||
|
||||
private function get_client_ip(): string {
|
||||
if (function_exists('aksrt_get_client_ip')) {
|
||||
return (string) aksrt_get_client_ip();
|
||||
}
|
||||
$keys = [
|
||||
'HTTP_CF_CONNECTING_IP',
|
||||
'HTTP_TRUE_CLIENT_IP',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
'HTTP_CLIENT_IP',
|
||||
];
|
||||
foreach ($keys as $key) {
|
||||
if (!empty($_SERVER[$key])) {
|
||||
$parts = explode(',', (string) $_SERVER[$key]);
|
||||
$ip = trim($parts[0]);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class OAuth {
|
||||
use OAuthPart1;
|
||||
use OAuthPart2;
|
||||
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('init', [$this, 'add_rewrite_rules']);
|
||||
add_action('template_redirect', [$this, 'handle_callback']);
|
||||
add_action('template_redirect', [$this, 'handle_clogin_routes'], 9);
|
||||
add_action('show_user_profile', [$this, 'profile_bindings'], 10, 1);
|
||||
add_action('wp_ajax_aksrt_oauth_unbind', [$this, 'ajax_unbind']);
|
||||
}
|
||||
|
||||
public function add_rewrite_rules(): void {
|
||||
add_rewrite_rule('^oauth/callback/?$', 'index.php?aksrt_oauth=callback', 'top');
|
||||
add_rewrite_rule('^oauth/clogin/?$', 'index.php?aksrt_oauth=clogin', 'top');
|
||||
add_rewrite_rule('^oauth/clogin/callback/?$', 'index.php?aksrt_oauth=clogin_callback', 'top');
|
||||
add_filter('query_vars', function($vars) {
|
||||
$vars[] = 'aksrt_oauth';
|
||||
return $vars;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成OAuth授权URL
|
||||
*/
|
||||
public function get_auth_url(string $provider, string $action = 'login'): string {
|
||||
$provider = sanitize_key($provider);
|
||||
if (str_starts_with($provider, 'clogin_') || str_starts_with($provider, 'clogin-')) {
|
||||
$type = str_replace(['clogin_', 'clogin-'], '', $provider);
|
||||
return $this->get_clogin_login_url($type, $action);
|
||||
}
|
||||
if ($provider === 'clogin') {
|
||||
return $this->get_clogin_login_url('qq', $action);
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::oauth_proxy_mode() === 'client') {
|
||||
if (class_exists('AKSRT\\Auth\\OAuthProxyClient')) {
|
||||
return OAuthProxyClient::get_instance()->get_proxy_auth_url($provider, $action);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
$config = $this->get_provider_config($provider);
|
||||
if (!$config) return '';
|
||||
|
||||
$uid = get_current_user_id();
|
||||
$nonce = wp_create_nonce('aksrt_oauth_' . $provider . '_' . $uid);
|
||||
$state = ($action === 'bind' && $uid)
|
||||
? 'bind_' . $provider . '_' . $nonce
|
||||
: $provider . '_' . $nonce;
|
||||
|
||||
$redirect_uri = !empty($config['redirect_uri']) ? $config['redirect_uri'] : home_url('/oauth/callback/');
|
||||
|
||||
try {
|
||||
switch ($provider) {
|
||||
case 'github':
|
||||
$oauth = new \Yurun\OAuthLogin\Github\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
return $oauth->getAuthUrl($redirect_uri, $state);
|
||||
|
||||
case 'gitee':
|
||||
$oauth = new \Yurun\OAuthLogin\Gitee\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
return $oauth->getAuthUrl($redirect_uri, $state);
|
||||
|
||||
case 'qq':
|
||||
$oauth = new \Yurun\OAuthLogin\QQ\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
return $oauth->getAuthUrl($redirect_uri, $state);
|
||||
|
||||
case 'weibo':
|
||||
$oauth = new \Yurun\OAuthLogin\Weibo\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
return $oauth->getAuthUrl($redirect_uri, $state);
|
||||
|
||||
case 'weixin':
|
||||
$oauth = new \Yurun\OAuthLogin\Weixin\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
return $oauth->getAuthUrl($redirect_uri, $state);
|
||||
|
||||
case 'baidu':
|
||||
$oauth = new \Yurun\OAuthLogin\Baidu\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
return $oauth->getAuthUrl($redirect_uri, $state);
|
||||
|
||||
case 'alipay':
|
||||
$oauth = new \Yurun\OAuthLogin\Alipay\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
return $oauth->getAuthUrl($redirect_uri, $state);
|
||||
|
||||
case 'google':
|
||||
return $this->get_google_auth_url($config, $redirect_uri, $state);
|
||||
|
||||
case 'weixingzh':
|
||||
return $this->get_weixingzh_auth_url($config, $redirect_uri, $state);
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理OAuth回调 — 路由分发
|
||||
*/
|
||||
public function handle_callback(): void {
|
||||
$provider = $this->resolve_callback_provider();
|
||||
if ($provider === '' || !in_array($provider, $this->get_enabled_providers())) return;
|
||||
|
||||
$error = $_GET['error'] ?? '';
|
||||
if ($error) {
|
||||
wp_die(__('登录失败:', 'aksrt') . esc_html($error));
|
||||
}
|
||||
|
||||
$code = $_GET['code'] ?? '';
|
||||
$state = $_GET['state'] ?? '';
|
||||
if (empty($code)) {
|
||||
wp_die(__('OAuth回调缺少授权码', 'aksrt'));
|
||||
}
|
||||
|
||||
$is_bind = strpos($state, 'bind_') === 0;
|
||||
$rest = $is_bind ? substr($state, 5) : $state;
|
||||
$sep = strpos($rest, '_');
|
||||
|
||||
if ($sep === false || $sep >= strlen($rest) - 1) {
|
||||
wp_die(__('无效的OAuth状态参数', 'aksrt'));
|
||||
}
|
||||
|
||||
$parsed_provider = sanitize_key(substr($rest, 0, $sep));
|
||||
if (!in_array($parsed_provider, $this->get_enabled_providers())) {
|
||||
wp_die(__('未知的OAuth提供商', 'aksrt'));
|
||||
}
|
||||
|
||||
$nonce_state = substr($rest, $sep + 1);
|
||||
|
||||
if ($is_bind) {
|
||||
$this->handle_bind_callback($parsed_provider, $code, $nonce_state);
|
||||
} else {
|
||||
$this->handle_login_callback($parsed_provider, $code, $nonce_state);
|
||||
}
|
||||
}
|
||||
|
||||
private function handle_login_callback(string $provider, string $code, string $nonce): void {
|
||||
try {
|
||||
if (!wp_verify_nonce($nonce, "aksrt_oauth_{$provider}_0")) {
|
||||
throw new \Exception(__('安全验证失败,请重试', 'aksrt'));
|
||||
}
|
||||
|
||||
$user_data = $this->get_user_data($provider, $code);
|
||||
|
||||
if (!$user_data || empty($user_data['provider_id'])) {
|
||||
throw new \Exception(__('无法获取用户信息', 'aksrt'));
|
||||
}
|
||||
|
||||
$user = $this->find_or_create_user($provider, $user_data);
|
||||
|
||||
if (is_wp_error($user) && $user->get_error_code() === 'oauth_no_match') {
|
||||
$error_data = $user->get_error_data();
|
||||
$obk = 'aksrt_ob_' . wp_generate_password(24, false);
|
||||
set_transient($obk, [
|
||||
'provider' => $error_data['provider'],
|
||||
'provider_id' => $error_data['user_data']['provider_id'] ?? '',
|
||||
'user_data' => $error_data['user_data'],
|
||||
], 15 * MINUTE_IN_SECONDS);
|
||||
|
||||
$bind_url = aksrt_get_bind_url();
|
||||
wp_safe_redirect(add_query_arg('obk', $obk, $bind_url));
|
||||
exit;
|
||||
}
|
||||
|
||||
if (is_wp_error($user)) {
|
||||
throw new \Exception($user->get_error_message());
|
||||
}
|
||||
|
||||
wp_set_auth_cookie($user->ID, true);
|
||||
$redirect_url = home_url('/');
|
||||
if (function_exists('aksrt_get_user_center_url')) {
|
||||
$redirect_url = aksrt_get_user_center_url();
|
||||
}
|
||||
wp_safe_redirect(add_query_arg(['oauth_action' => 'login', 'oauth_bound' => $provider], $redirect_url));
|
||||
exit;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
wp_die(__('登录失败:', 'aksrt') . '<br>' . esc_html($e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private function handle_bind_callback(string $provider, string $code, string $nonce): void {
|
||||
$current_uid = get_current_user_id();
|
||||
if (!$current_uid) {
|
||||
wp_safe_redirect(aksrt_get_login_url());
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$expected = wp_create_nonce("aksrt_oauth_{$provider}_{$current_uid}");
|
||||
if (!hash_equals($expected, $nonce)) {
|
||||
throw new \Exception(__('安全验证失败,请重试', 'aksrt'));
|
||||
}
|
||||
|
||||
$existing_binding = get_user_meta($current_uid, "aksrt_{$provider}_id", true);
|
||||
if (!empty($existing_binding)) {
|
||||
wp_safe_redirect(add_query_arg(['tab' => 'settings', 'oauth_action' => 'bind', 'oauth_bound' => $provider], $this->get_profile_url()));
|
||||
exit;
|
||||
}
|
||||
|
||||
$user_data = $this->get_user_data($provider, $code);
|
||||
|
||||
if (!$user_data || empty($user_data['provider_id'])) {
|
||||
throw new \Exception(__('无法获取用户信息', 'aksrt'));
|
||||
}
|
||||
|
||||
$existing = $this->find_user_by_provider($provider, $user_data['provider_id'] ?? '');
|
||||
if ($existing && $existing->ID !== $current_uid) {
|
||||
throw new \Exception(__('该第三方账号已被其他用户绑定', 'aksrt'));
|
||||
}
|
||||
|
||||
$this->bind_provider_to_user($current_uid, $provider, $user_data);
|
||||
|
||||
wp_safe_redirect(add_query_arg(['tab' => 'settings', 'oauth_action' => 'bind', 'oauth_bound' => $provider], $this->get_profile_url()));
|
||||
exit;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
wp_die(__('绑定失败:', 'aksrt') . '<br>' . esc_html($e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过授权码获取用户数据(统一输出格式)
|
||||
*/
|
||||
private function get_user_data(string $provider, string $code): array {
|
||||
$config = $this->get_provider_config($provider);
|
||||
$redirect_uri = !empty($config['redirect_uri']) ? $config['redirect_uri'] : home_url('/oauth/callback/');
|
||||
|
||||
switch ($provider) {
|
||||
case 'github':
|
||||
return $this->get_github_user_data($config, $code, $redirect_uri);
|
||||
case 'gitee':
|
||||
return $this->get_gitee_user_data($config, $code, $redirect_uri);
|
||||
case 'qq':
|
||||
return $this->get_qq_user_data($config, $code, $redirect_uri);
|
||||
case 'weibo':
|
||||
return $this->get_weibo_user_data($config, $code, $redirect_uri);
|
||||
case 'weixin':
|
||||
return $this->get_weixin_user_data($config, $code, $redirect_uri);
|
||||
case 'baidu':
|
||||
return $this->get_baidu_user_data($config, $code, $redirect_uri);
|
||||
case 'alipay':
|
||||
return $this->get_alipay_user_data($config, $code, $redirect_uri);
|
||||
case 'google':
|
||||
return $this->get_google_user_data($config, $code, $redirect_uri);
|
||||
case 'weixingzh':
|
||||
return $this->get_weixingzh_user_data($config, $code, $redirect_uri);
|
||||
default:
|
||||
throw new \Exception(__('不支持的OAuth平台:', 'aksrt') . $provider);
|
||||
}
|
||||
}
|
||||
|
||||
private function get_github_user_data(array $config, string $code, string $redirect_uri): array {
|
||||
$oauth = new \Yurun\OAuthLogin\Github\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
|
||||
$oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null);
|
||||
$userInfo = $oauth->getUserInfo();
|
||||
|
||||
// 获取邮箱
|
||||
$email = $userInfo['email'] ?? '';
|
||||
if (empty($email)) {
|
||||
$emails_json = $oauth->http->header('Authorization', ' token ' . $oauth->accessToken)
|
||||
->get('https://api.github.com/user/emails')
|
||||
->body();
|
||||
$emails = json_decode($emails_json, true) ?: [];
|
||||
foreach ($emails as $em) {
|
||||
if (!empty($em['primary']) && !empty($em['email'])) {
|
||||
$email = $em['email'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'provider' => 'github',
|
||||
'provider_id' => (string) ($userInfo['id'] ?? ''),
|
||||
'username' => $userInfo['login'] ?? '',
|
||||
'name' => $userInfo['name'] ?? '',
|
||||
'email' => $email,
|
||||
'avatar_url' => $userInfo['avatar_url'] ?? '',
|
||||
'url' => $userInfo['html_url'] ?? '',
|
||||
'bio' => $userInfo['bio'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
private function get_gitee_user_data(array $config, string $code, string $redirect_uri): array {
|
||||
$oauth = new \Yurun\OAuthLogin\Gitee\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
|
||||
$oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null);
|
||||
$userInfo = $oauth->getUserInfo();
|
||||
|
||||
return [
|
||||
'provider' => 'gitee',
|
||||
'provider_id' => (string) ($userInfo['id'] ?? ''),
|
||||
'username' => $userInfo['login'] ?? '',
|
||||
'name' => $userInfo['name'] ?? '',
|
||||
'email' => $userInfo['email'] ?? '',
|
||||
'avatar_url' => $userInfo['avatar_url'] ?? '',
|
||||
'url' => $userInfo['html_url'] ?? '',
|
||||
'bio' => $userInfo['bio'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
private function get_qq_user_data(array $config, string $code, string $redirect_uri): array {
|
||||
$oauth = new \Yurun\OAuthLogin\QQ\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
|
||||
$oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null);
|
||||
$openid = $oauth->getOpenID();
|
||||
$userInfo = $oauth->getUserInfo();
|
||||
|
||||
return [
|
||||
'provider' => 'qq',
|
||||
'provider_id' => $openid,
|
||||
'username' => $userInfo['nickname'] ?? '',
|
||||
'name' => $userInfo['nickname'] ?? '',
|
||||
'email' => '',
|
||||
'avatar_url' => $userInfo['figureurl_qq_2'] ?? $userInfo['figureurl_2'] ?? $userInfo['figureurl'] ?? '',
|
||||
'gender' => $userInfo['gender'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
private function get_weibo_user_data(array $config, string $code, string $redirect_uri): array {
|
||||
$oauth = new \Yurun\OAuthLogin\Weibo\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
|
||||
$oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null);
|
||||
$userInfo = $oauth->getUserInfo();
|
||||
|
||||
return [
|
||||
'provider' => 'weibo',
|
||||
'provider_id' => (string) ($userInfo['id'] ?? ''),
|
||||
'username' => $userInfo['screen_name'] ?? '',
|
||||
'name' => $userInfo['name'] ?? '',
|
||||
'email' => '',
|
||||
'avatar_url' => $userInfo['avatar_large'] ?? $userInfo['profile_image_url'] ?? '',
|
||||
'gender' => ($userInfo['gender'] ?? '') === 'm' ? 'male' : (($userInfo['gender'] ?? '') === 'f' ? 'female' : ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function get_weixin_user_data(array $config, string $code, string $redirect_uri): array {
|
||||
$oauth = new \Yurun\OAuthLogin\Weixin\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
|
||||
$oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null);
|
||||
$userInfo = $oauth->getUserInfo();
|
||||
|
||||
return [
|
||||
'provider' => 'weixin',
|
||||
'provider_id' => (string) ($userInfo['openid'] ?? ''),
|
||||
'username' => $userInfo['nickname'] ?? '',
|
||||
'name' => $userInfo['nickname'] ?? '',
|
||||
'email' => '',
|
||||
'avatar_url' => $userInfo['headimgurl'] ?? '',
|
||||
'gender' => ($userInfo['sex'] ?? 0) == 1 ? 'male' : (($userInfo['sex'] ?? 0) == 2 ? 'female' : ''),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait OAuthPart1 {
|
||||
|
||||
private function get_baidu_user_data(array $config, string $code, string $redirect_uri): array {
|
||||
$oauth = new \Yurun\OAuthLogin\Baidu\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
|
||||
$oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null);
|
||||
$userInfo = $oauth->getUserInfo();
|
||||
|
||||
return [
|
||||
'provider' => 'baidu',
|
||||
'provider_id' => (string) ($userInfo['userid'] ?? ''),
|
||||
'username' => $userInfo['username'] ?? '',
|
||||
'name' => $userInfo['realname'] ?? $userInfo['username'] ?? '',
|
||||
'email' => $userInfo['email'] ?? '',
|
||||
'avatar_url' => $userInfo['portrait'] ? ('https://himg.bdimg.com/sys/portrait/item/' . $userInfo['portrait'] . '.jpg') : '',
|
||||
];
|
||||
}
|
||||
|
||||
private function get_alipay_user_data(array $config, string $code, string $redirect_uri): array {
|
||||
$oauth = new \Yurun\OAuthLogin\Alipay\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
|
||||
$oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null);
|
||||
$userInfo = $oauth->getUserInfo();
|
||||
|
||||
return [
|
||||
'provider' => 'alipay',
|
||||
'provider_id' => (string) ($userInfo['user_id'] ?? ''),
|
||||
'username' => $userInfo['user_name'] ?? '',
|
||||
'name' => $userInfo['user_name'] ?? '',
|
||||
'email' => $userInfo['email'] ?? '',
|
||||
'avatar_url' => $userInfo['avatar'] ?? '',
|
||||
'gender' => ($userInfo['gender'] ?? '') === 'm' ? 'male' : (($userInfo['gender'] ?? '') === 'f' ? 'female' : ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function get_google_auth_url(array $config, string $redirect_uri, string $state): string {
|
||||
$params = http_build_query([
|
||||
'client_id' => $config['client_id'],
|
||||
'redirect_uri' => $redirect_uri,
|
||||
'response_type' => 'code',
|
||||
'scope' => 'openid email profile',
|
||||
'state' => $state,
|
||||
'access_type' => 'offline',
|
||||
]);
|
||||
return 'https://accounts.google.com/o/oauth2/v2/auth?' . $params;
|
||||
}
|
||||
|
||||
private function get_google_user_data(array $config, string $code, string $redirect_uri): array {
|
||||
// 换取 token
|
||||
$response = wp_remote_post('https://oauth2.googleapis.com/token', [
|
||||
'body' => [
|
||||
'code' => $code,
|
||||
'client_id' => $config['client_id'],
|
||||
'client_secret' => $config['client_secret'],
|
||||
'redirect_uri' => $redirect_uri,
|
||||
'grant_type' => 'authorization_code',
|
||||
],
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
throw new \Exception($response->get_error_message());
|
||||
}
|
||||
|
||||
$token_data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (empty($token_data['access_token'])) {
|
||||
throw new \Exception(__('Google token 获取失败', 'aksrt'));
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
$user_response = wp_remote_get('https://www.googleapis.com/oauth2/v2/userinfo', [
|
||||
'headers' => ['Authorization' => 'Bearer ' . $token_data['access_token']],
|
||||
]);
|
||||
|
||||
if (is_wp_error($user_response)) {
|
||||
throw new \Exception($user_response->get_error_message());
|
||||
}
|
||||
|
||||
$user_info = json_decode(wp_remote_retrieve_body($user_response), true);
|
||||
|
||||
return [
|
||||
'provider' => 'google',
|
||||
'provider_id' => (string) ($user_info['id'] ?? ''),
|
||||
'username' => $user_info['email'] ?? '',
|
||||
'name' => $user_info['name'] ?? '',
|
||||
'email' => $user_info['email'] ?? '',
|
||||
'email_verified' => !empty($user_info['verified_email']),
|
||||
'avatar_url' => $user_info['picture'] ?? '',
|
||||
'locale' => $user_info['locale'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信公众号 OAuth2 授权 URL
|
||||
*
|
||||
* 使用 snsapi_userinfo scope 获取用户信息(需已认证服务号或订阅号+验证)。
|
||||
*/
|
||||
private function get_weixingzh_auth_url(array $config, string $redirect_uri, string $state): string {
|
||||
$params = http_build_query([
|
||||
'appid' => $config['client_id'],
|
||||
'redirect_uri' => $redirect_uri,
|
||||
'response_type' => 'code',
|
||||
'scope' => 'snsapi_userinfo',
|
||||
'state' => $state,
|
||||
]);
|
||||
return 'https://open.weixin.qq.com/connect/oauth2/authorize?' . $params . '#wechat_redirect';
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信公众号 OAuth2 获取用户信息
|
||||
*
|
||||
* 1. code → access_token + openid
|
||||
* 2. access_token + openid → userinfo
|
||||
*/
|
||||
private function get_weixingzh_user_data(array $config, string $code, string $redirect_uri): array {
|
||||
// 1. 换取 access_token
|
||||
$token_url = add_query_arg([
|
||||
'appid' => $config['client_id'],
|
||||
'secret' => $config['client_secret'],
|
||||
'code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
], 'https://api.weixin.qq.com/sns/oauth2/access_token');
|
||||
|
||||
$response = wp_remote_get($token_url);
|
||||
if (is_wp_error($response)) {
|
||||
throw new \Exception($response->get_error_message());
|
||||
}
|
||||
|
||||
$token_data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (empty($token_data['access_token']) || empty($token_data['openid'])) {
|
||||
$errmsg = $token_data['errmsg'] ?? __('微信公众号 token 获取失败', 'aksrt');
|
||||
throw new \Exception($errmsg);
|
||||
}
|
||||
|
||||
// 2. 获取用户信息
|
||||
$user_url = add_query_arg([
|
||||
'access_token' => $token_data['access_token'],
|
||||
'openid' => $token_data['openid'],
|
||||
'lang' => 'zh_CN',
|
||||
], 'https://api.weixin.qq.com/sns/userinfo');
|
||||
|
||||
$user_response = wp_remote_get($user_url);
|
||||
if (is_wp_error($user_response)) {
|
||||
throw new \Exception($user_response->get_error_message());
|
||||
}
|
||||
|
||||
$user_info = json_decode(wp_remote_retrieve_body($user_response), true);
|
||||
|
||||
return [
|
||||
'provider' => 'weixingzh',
|
||||
'provider_id' => (string) ($user_info['openid'] ?? $token_data['openid']),
|
||||
'username' => $user_info['nickname'] ?? '',
|
||||
'name' => $user_info['nickname'] ?? '',
|
||||
'email' => '',
|
||||
'email_verified' => false,
|
||||
'avatar_url' => $user_info['headimgurl'] ?? '',
|
||||
'locale' => 'zh_CN',
|
||||
'unionid' => $user_info['unionid'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
private function resolve_callback_provider(): string {
|
||||
$state = $_GET['state'] ?? '';
|
||||
if ($state === '') return '';
|
||||
|
||||
$rest = (strpos($state, 'bind_') === 0) ? substr($state, 5) : $state;
|
||||
$sep = strpos($rest, '_');
|
||||
if ($sep !== false && $sep < strlen($rest) - 1) {
|
||||
return sanitize_key(substr($rest, 0, $sep));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function get_enabled_providers(): array {
|
||||
if (class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::oauth_proxy_mode() === 'client') {
|
||||
return \AKSRT\Settings\Settings::oauth_proxy_client_providers();
|
||||
}
|
||||
|
||||
$providers = get_option('aksrt_oauth', []);
|
||||
$enabled = [];
|
||||
foreach (['github', 'gitee', 'qq', 'google', 'weibo', 'weixin', 'baidu', 'alipay', 'weixingzh'] as $key) {
|
||||
if (!empty($providers[$key]['enabled']) && !empty($providers[$key]['client_id'])) {
|
||||
$enabled[] = $key;
|
||||
continue;
|
||||
}
|
||||
// BC: also check aksrt_settings flat keys
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$s = \AKSRT\Settings\Settings::get_all();
|
||||
$switch_key = 'oauth_' . $key . '_s';
|
||||
if (!empty($s[$switch_key])) {
|
||||
$id_key = $key . '_appid';
|
||||
$sec_key = $key . '_appsecret';
|
||||
if ($key === 'weixingzh') {
|
||||
$sec_key = 'weixingzh_appsecret';
|
||||
} elseif ($key === 'github' || $key === 'gitee' || $key === 'qq') {
|
||||
$sec_key = $key . '_appkey';
|
||||
}
|
||||
if (!empty($s[$id_key]) && !empty($s[$sec_key])) {
|
||||
$enabled[] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rainbow aggregate (clogin) providers as virtual keys: clogin_qq, clogin_weixin...
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$s = \AKSRT\Settings\Settings::get_all();
|
||||
if (!empty($s['clogin_s']) && !empty($s['clogin_url']) && !empty($s['clogin_id']) && !empty($s['clogin_key'])) {
|
||||
$raw = $s['clogin_oauth_s'] ?? $s['clogin_types'] ?? ['qq', 'weixin', 'weibo', 'alipay'];
|
||||
if (is_string($raw)) {
|
||||
$raw = preg_split('/[,,\s]+/', $raw) ?: [];
|
||||
}
|
||||
if (!is_array($raw)) {
|
||||
$raw = [];
|
||||
}
|
||||
foreach ($raw as $ctype) {
|
||||
$ctype = sanitize_key((string) $ctype);
|
||||
if ($ctype === '') {
|
||||
continue;
|
||||
}
|
||||
$enabled[] = 'clogin_' . $ctype;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($enabled));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OAuth 提供商数据(供模板使用)
|
||||
* 返回 [{name, label, icon, url}, ...]
|
||||
*/
|
||||
public function get_auth_providers_data(): array {
|
||||
$platform_names = [
|
||||
'github' => 'GitHub',
|
||||
'gitee' => 'Gitee',
|
||||
'qq' => 'QQ',
|
||||
'google' => 'Google',
|
||||
'weibo' => 'Weibo',
|
||||
'weixin' => 'WeChat',
|
||||
'baidu' => 'Baidu',
|
||||
'alipay' => 'Alipay',
|
||||
'weixingzh' => '微信公众号',
|
||||
];
|
||||
|
||||
$result = [];
|
||||
foreach ($this->get_enabled_providers() as $key) {
|
||||
$url = $this->get_auth_url($key);
|
||||
if (empty($url)) continue;
|
||||
|
||||
$label = $platform_names[$key] ?? '';
|
||||
$icon_key = $key;
|
||||
if (str_starts_with($key, 'clogin_')) {
|
||||
$ctype = substr($key, 7);
|
||||
$label = ($platform_names[$ctype] ?? strtoupper($ctype)) . ' · 聚合';
|
||||
$icon_key = $ctype === 'weixin' ? 'weixin' : $ctype;
|
||||
}
|
||||
if ($label === '') {
|
||||
$label = ucfirst($key);
|
||||
}
|
||||
$result[] = [
|
||||
'name' => $key,
|
||||
'label' => $label,
|
||||
'icon' => function_exists('aksrt_brand_icon') ? aksrt_brand_icon((string) $icon_key) : '',
|
||||
'url' => esc_url($url),
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function get_provider_config(string $provider): ?array {
|
||||
$providers = get_option('aksrt_oauth', []);
|
||||
$config = $providers[$provider] ?? null;
|
||||
if (!empty($config['client_id']) && !empty($config['client_secret'])) {
|
||||
return $config;
|
||||
}
|
||||
|
||||
// BC: fallback to aksrt_settings flat keys
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$s = \AKSRT\Settings\Settings::get_all();
|
||||
$id_key = $provider . '_appid';
|
||||
$sec_key = $provider . '_appsecret';
|
||||
if ($provider === 'github' || $provider === 'gitee' || $provider === 'qq') {
|
||||
$sec_key = $provider . '_appkey';
|
||||
} elseif ($provider === 'google') {
|
||||
$id_key = 'google_client_id';
|
||||
$sec_key = 'google_client_secret';
|
||||
}
|
||||
$client_id = (string) ($s[$id_key] ?? '');
|
||||
$client_secret = (string) ($s[$sec_key] ?? '');
|
||||
if ($client_id !== '' && $client_secret !== '') {
|
||||
return [
|
||||
'client_id' => $client_id,
|
||||
'client_secret' => $client_secret,
|
||||
'redirect_uri' => home_url('/oauth/callback/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function find_or_create_user($provider, $user_data) {
|
||||
$email = $user_data['email'] ?? '';
|
||||
|
||||
if (!empty($user_data['provider_id'])) {
|
||||
$by_provider = $this->find_user_by_provider($provider, (string) $user_data['provider_id']);
|
||||
if ($by_provider instanceof \WP_User) {
|
||||
$bindings = get_user_meta($by_provider->ID, 'aksrt_oauth_bindings', true) ?: [];
|
||||
if (!is_array($bindings)) $bindings = [];
|
||||
if (!in_array($provider, $bindings, true)) {
|
||||
$bindings[] = $provider;
|
||||
update_user_meta($by_provider->ID, 'aksrt_oauth_bindings', $bindings);
|
||||
}
|
||||
if (!empty($user_data['avatar_url'])) {
|
||||
update_user_meta($by_provider->ID, 'aksrt_oauth_avatar', esc_url_raw($user_data['avatar_url']));
|
||||
}
|
||||
return $by_provider;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($email)) {
|
||||
$existing = get_user_by('email', $email);
|
||||
if ($existing && !$existing instanceof \WP_Error) {
|
||||
$bindings = get_user_meta($existing->ID, 'aksrt_oauth_bindings', true) ?: [];
|
||||
if (!is_array($bindings)) $bindings = [];
|
||||
if (!in_array($provider, $bindings, true)) {
|
||||
$bindings[] = $provider;
|
||||
update_user_meta($existing->ID, 'aksrt_oauth_bindings', $bindings);
|
||||
}
|
||||
if (!empty($user_data['provider_id'])) {
|
||||
update_user_meta($existing->ID, "aksrt_{$provider}_id", (string) $user_data['provider_id']);
|
||||
}
|
||||
if (!empty($user_data['avatar_url'])) {
|
||||
update_user_meta($existing->ID, 'aksrt_oauth_avatar', esc_url_raw($user_data['avatar_url']));
|
||||
}
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_Error(
|
||||
'oauth_no_match',
|
||||
__('需要绑定已有账号', 'aksrt'),
|
||||
[
|
||||
'provider' => $provider,
|
||||
'user_data' => $user_data,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 前端UI方法
|
||||
// ============================================
|
||||
|
||||
public function login_button(): void {
|
||||
echo $this->render_social_buttons(__('使用以下方式快速登录', 'aksrt'));
|
||||
}
|
||||
|
||||
public function register_button(): void {
|
||||
echo $this->render_social_buttons(__('使用以下方式快速注册', 'aksrt'));
|
||||
}
|
||||
|
||||
public function render_social_buttons(string $title = ''): string {
|
||||
$items = $this->get_auth_providers_data();
|
||||
if (empty($items)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="social-login-divider">
|
||||
<span><?php echo esc_html($title) ?: esc_html__('or', 'aksrt'); ?></span>
|
||||
</div>
|
||||
<div class="oauth-row">
|
||||
<?php foreach ($items as $item):
|
||||
$key = (string) ($item['name'] ?? '');
|
||||
$url = (string) ($item['url'] ?? '');
|
||||
if ($key === '' || $url === '') continue;
|
||||
?>
|
||||
<a href="<?php echo esc_url($url); ?>"
|
||||
class="oauth-btn oauth-btn--<?php echo esc_attr($key); ?>"
|
||||
data-provider="<?php echo esc_attr($key); ?>"
|
||||
aria-label="<?php echo esc_attr((string) ($item['label'] ?? $key)); ?>"
|
||||
title="<?php echo esc_attr((string) ($item['label'] ?? $key)); ?>">
|
||||
<?php echo $item['icon'] ?? ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
public function get_login_buttons(): string {
|
||||
return $this->render_social_buttons();
|
||||
}
|
||||
|
||||
public function login_message(string $message): string {
|
||||
return $message . self::get_instance()->render_social_buttons();
|
||||
}
|
||||
|
||||
public function register_message(string $message): string {
|
||||
return $message . self::get_instance()->render_social_buttons(__('或使用社交账号快速注册', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,url:string,id:string,key:string,types:string[]}
|
||||
*/
|
||||
private function get_clogin_settings(): array {
|
||||
$s = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
$raw = $s['clogin_oauth_s'] ?? $s['clogin_types'] ?? ['qq', 'weixin', 'weibo', 'alipay'];
|
||||
if (is_string($raw)) {
|
||||
$raw = preg_split('/[,,\s]+/', $raw) ?: [];
|
||||
}
|
||||
if (!is_array($raw)) {
|
||||
$raw = [];
|
||||
}
|
||||
$types = [];
|
||||
foreach ($raw as $t) {
|
||||
$t = sanitize_key((string) $t);
|
||||
if ($t !== '') {
|
||||
$types[] = $t;
|
||||
}
|
||||
}
|
||||
return [
|
||||
'enabled' => !empty($s['clogin_s']),
|
||||
'url' => esc_url_raw((string) ($s['clogin_url'] ?? '')),
|
||||
'id' => (string) ($s['clogin_id'] ?? ''),
|
||||
'key' => (string) ($s['clogin_key'] ?? ''),
|
||||
'types' => array_values(array_unique($types)),
|
||||
];
|
||||
}
|
||||
|
||||
private function get_clogin_login_url(string $type, string $action = 'login'): string {
|
||||
$cfg = $this->get_clogin_settings();
|
||||
$type = sanitize_key($type);
|
||||
if (!$cfg['enabled'] || $cfg['url'] === '' || $cfg['id'] === '' || $cfg['key'] === '' || $type === '') {
|
||||
return '';
|
||||
}
|
||||
if (!empty($cfg['types']) && !in_array($type, $cfg['types'], true)) {
|
||||
return '';
|
||||
}
|
||||
$args = ['type' => $type];
|
||||
if ($action === 'bind' && is_user_logged_in()) {
|
||||
$args['bind'] = 1;
|
||||
}
|
||||
$rurl = isset($_GET['redirect_to']) ? esc_url_raw(wp_unslash((string) $_GET['redirect_to'])) : '';
|
||||
if ($rurl !== '') {
|
||||
$args['rurl'] = $rurl;
|
||||
}
|
||||
return add_query_arg($args, home_url('/oauth/clogin/'));
|
||||
}
|
||||
|
||||
public function handle_clogin_routes(): void {
|
||||
$route = get_query_var('aksrt_oauth');
|
||||
if ($route === 'clogin') {
|
||||
$this->handle_clogin_login();
|
||||
return;
|
||||
}
|
||||
if ($route === 'clogin_callback') {
|
||||
$this->handle_clogin_callback();
|
||||
}
|
||||
}
|
||||
|
||||
private function handle_clogin_login(): void {
|
||||
$cfg = $this->get_clogin_settings();
|
||||
$type = isset($_GET['type']) ? sanitize_key(wp_unslash((string) $_GET['type'])) : '';
|
||||
if (!$cfg['enabled'] || $cfg['url'] === '' || $cfg['id'] === '' || $cfg['key'] === '' || $type === '') {
|
||||
wp_die(esc_html__('暂未启用此登录方式', 'aksrt'));
|
||||
}
|
||||
if (!empty($cfg['types']) && !in_array($type, $cfg['types'], true)) {
|
||||
wp_die(esc_html__('此登录方式未启用', 'aksrt'));
|
||||
}
|
||||
if (!class_exists(CLoginClient::class)) {
|
||||
wp_die(esc_html__('聚合登录组件未加载', 'aksrt'));
|
||||
}
|
||||
|
||||
$client = new CLoginClient([
|
||||
'apiurl' => $cfg['url'],
|
||||
'appid' => $cfg['id'],
|
||||
'appkey' => $cfg['key'],
|
||||
'callback' => home_url('/oauth/clogin/callback'),
|
||||
]);
|
||||
$resp = $client->login($type);
|
||||
if (!empty($resp['code']) && (int) $resp['code'] !== 0 && !empty($resp['msg'])) {
|
||||
wp_die(esc_html(sprintf(__('处理失败:%s', 'aksrt'), (string) $resp['msg'])));
|
||||
}
|
||||
if (empty($resp['url'])) {
|
||||
wp_die(esc_html__('无法获取聚合登录地址', 'aksrt'));
|
||||
}
|
||||
|
||||
if (!session_id()) {
|
||||
@session_start();
|
||||
}
|
||||
$_SESSION['AKSRT_CLOGIN_STATE'] = $client->state;
|
||||
$_SESSION['AKSRT_CLOGIN_TYPE'] = $type;
|
||||
$_SESSION['AKSRT_CLOGIN_RURL'] = !empty($_GET['rurl']) ? esc_url_raw(wp_unslash((string) $_GET['rurl'])) : '';
|
||||
$_SESSION['AKSRT_CLOGIN_BIND'] = !empty($_GET['bind']) ? 1 : 0;
|
||||
|
||||
wp_redirect(esc_url_raw((string) $resp['url']));
|
||||
exit;
|
||||
}
|
||||
|
||||
private function handle_clogin_callback(): void {
|
||||
if (!session_id()) {
|
||||
@session_start();
|
||||
}
|
||||
$state = isset($_GET['state']) ? (string) wp_unslash($_GET['state']) : '';
|
||||
$code = isset($_GET['code']) ? (string) wp_unslash($_GET['code']) : '';
|
||||
if ($code === '' || $state === '' || empty($_SESSION['AKSRT_CLOGIN_STATE']) || !hash_equals((string) $_SESSION['AKSRT_CLOGIN_STATE'], $state)) {
|
||||
wp_die(esc_html__('环境异常,state 验证失败,请刷新后重试', 'aksrt'));
|
||||
}
|
||||
|
||||
$cfg = $this->get_clogin_settings();
|
||||
if (!$cfg['enabled']) {
|
||||
wp_die(esc_html__('暂未启用此功能', 'aksrt'));
|
||||
}
|
||||
$client = new CLoginClient([
|
||||
'apiurl' => $cfg['url'],
|
||||
'appid' => $cfg['id'],
|
||||
'appkey' => $cfg['key'],
|
||||
]);
|
||||
$info = $client->callback($code);
|
||||
if (!empty($info['code']) && (int) $info['code'] !== 0 && !empty($info['msg'])) {
|
||||
wp_die(esc_html((string) $info['msg']));
|
||||
}
|
||||
$openid = (string) ($info['social_uid'] ?? '');
|
||||
$api_type = (string) ($info['type'] ?? ($_SESSION['AKSRT_CLOGIN_TYPE'] ?? ''));
|
||||
$provider_type = $client->from_api_type($api_type);
|
||||
if ($openid === '' || $provider_type === '') {
|
||||
wp_die(esc_html__('无法获取用户信息', 'aksrt'));
|
||||
}
|
||||
|
||||
$provider = 'clogin_' . sanitize_key($provider_type);
|
||||
$user_data = [
|
||||
'provider' => $provider,
|
||||
'provider_id' => $openid,
|
||||
'username' => (string) ($info['nickname'] ?? ''),
|
||||
'name' => (string) ($info['nickname'] ?? ''),
|
||||
'email' => '',
|
||||
'avatar_url' => (string) ($info['faceimg'] ?? ''),
|
||||
];
|
||||
if ($user_data['avatar_url'] !== '' && str_contains($user_data['avatar_url'], 'no_portrait')) {
|
||||
$user_data['avatar_url'] = '';
|
||||
}
|
||||
|
||||
$is_bind = !empty($_SESSION['AKSRT_CLOGIN_BIND']);
|
||||
$rurl = !empty($_SESSION['AKSRT_CLOGIN_RURL']) ? (string) $_SESSION['AKSRT_CLOGIN_RURL'] : '';
|
||||
unset($_SESSION['AKSRT_CLOGIN_STATE'], $_SESSION['AKSRT_CLOGIN_TYPE'], $_SESSION['AKSRT_CLOGIN_RURL'], $_SESSION['AKSRT_CLOGIN_BIND']);
|
||||
|
||||
try {
|
||||
if ($is_bind) {
|
||||
$uid = get_current_user_id();
|
||||
if ($uid <= 0) {
|
||||
wp_safe_redirect(function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url());
|
||||
exit;
|
||||
}
|
||||
$existing = $this->find_user_by_provider($provider, $openid);
|
||||
if ($existing && (int) $existing->ID !== $uid) {
|
||||
throw new \Exception(__('该第三方账号已被其他用户绑定', 'aksrt'));
|
||||
}
|
||||
$this->bind_provider_to_user($uid, $provider, $user_data);
|
||||
$redirect = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/');
|
||||
wp_safe_redirect(add_query_arg(['tab' => 'settings', 'oauth_action' => 'bind', 'oauth_bound' => $provider], $redirect));
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = $this->find_or_create_user($provider, $user_data);
|
||||
if (is_wp_error($user) && $user->get_error_code() === 'oauth_no_match') {
|
||||
// Auto-create account for aggregate login when no match (zibll-like).
|
||||
$login = 'u' . wp_generate_password(10, false, false);
|
||||
$uid = wp_insert_user([
|
||||
'user_login' => $login,
|
||||
'user_pass' => wp_generate_password(24, true, true),
|
||||
'display_name' => $user_data['name'] !== '' ? $user_data['name'] : $login,
|
||||
'nickname' => $user_data['name'] !== '' ? $user_data['name'] : $login,
|
||||
'role' => get_option('default_role', 'subscriber'),
|
||||
]);
|
||||
if (is_wp_error($uid)) {
|
||||
throw new \Exception($uid->get_error_message());
|
||||
}
|
||||
$this->bind_provider_to_user((int) $uid, $provider, $user_data);
|
||||
$user = get_user_by('id', (int) $uid);
|
||||
}
|
||||
if (is_wp_error($user) || !$user instanceof \WP_User) {
|
||||
throw new \Exception(is_wp_error($user) ? $user->get_error_message() : __('登录失败', 'aksrt'));
|
||||
}
|
||||
wp_set_current_user($user->ID);
|
||||
wp_set_auth_cookie($user->ID, true);
|
||||
do_action('wp_login', $user->user_login, $user);
|
||||
|
||||
$redirect = $rurl !== '' ? $rurl : (function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'));
|
||||
wp_safe_redirect(add_query_arg(['oauth_action' => 'login', 'oauth_bound' => $provider], $redirect));
|
||||
exit;
|
||||
} catch (\Exception $e) {
|
||||
wp_die(esc_html__('登录失败:', 'aksrt') . '<br>' . esc_html($e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait OAuthPart2 {
|
||||
|
||||
|
||||
public function get_frontend_bindings_data(int $user_id): array {
|
||||
$providers = get_option('aksrt_oauth', []);
|
||||
$bound_list = get_user_meta($user_id, 'aksrt_oauth_bindings', true) ?: [];
|
||||
if (!is_array($bound_list)) $bound_list = [];
|
||||
|
||||
$platform_names = [
|
||||
'github' => 'GitHub', 'gitee' => 'Gitee',
|
||||
'qq' => 'QQ', 'google' => 'Google',
|
||||
'weibo' => 'Weibo', 'weixin' => 'WeChat',
|
||||
'baidu' => 'Baidu', 'alipay' => 'Alipay',
|
||||
];
|
||||
$icons = [
|
||||
'github' => aksrt_brand_icon('github'),
|
||||
'gitee' => aksrt_brand_icon('gitee'),
|
||||
'qq' => aksrt_brand_icon('qq'),
|
||||
'google' => aksrt_brand_icon('google'),
|
||||
'weibo' => aksrt_brand_icon('weibo'),
|
||||
'weixin' => aksrt_brand_icon('weixin'),
|
||||
'baidu' => aksrt_brand_icon('baidu'),
|
||||
'alipay' => aksrt_brand_icon('alipay'),
|
||||
];
|
||||
|
||||
$bindings = [];
|
||||
foreach ($bound_list as $binding) {
|
||||
if (in_array($binding, ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin', 'baidu', 'alipay'], true) && !empty($providers[$binding]['enabled'])) {
|
||||
$bindings[] = [
|
||||
'key' => $binding,
|
||||
'name' => $platform_names[$binding] ?? ucfirst($binding),
|
||||
'icon' => $icons[$binding] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$available = [];
|
||||
foreach (['github', 'gitee', 'qq', 'google', 'weibo', 'weixin', 'baidu', 'alipay'] as $key) {
|
||||
if (in_array($key, $bound_list, true)) continue;
|
||||
if (empty($providers[$key]['enabled']) || empty($providers[$key]['client_id'])) continue;
|
||||
$url = $this->get_auth_url($key, 'bind');
|
||||
if (empty($url)) continue;
|
||||
|
||||
$available[] = [
|
||||
'key' => $key,
|
||||
'name' => $platform_names[$key],
|
||||
'icon' => $icons[$key] ?? '',
|
||||
'url' => esc_url($url),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'bindings' => $bindings,
|
||||
'available' => $available,
|
||||
];
|
||||
}
|
||||
|
||||
public function profile_bindings(\WP_User $user): void {
|
||||
$bindings = get_user_meta($user->ID, 'aksrt_oauth_bindings', true) ?: [];
|
||||
if (empty($bindings)) return;
|
||||
|
||||
$unbind_nonce = wp_create_nonce('aksrt_oauth_unbind');
|
||||
?>
|
||||
<h2><?php _e('社交账号绑定', 'aksrt'); ?></h2>
|
||||
<table class="form-table">
|
||||
<?php foreach ($bindings as $binding): ?>
|
||||
<tr>
|
||||
<th scope="row"><?php echo esc_html(ucfirst($binding)); ?></th>
|
||||
<td><span class="badge" style="background:#d1fae5;color:#065f46;padding:4px 12px;border-radius:12px;font-size:12px;">✓ <?php esc_html_e('已绑定', 'aksrt'); ?></span></td>
|
||||
<td>
|
||||
<button type="button" class="button button-link-delete aksrt-oauth-unbind-btn"
|
||||
data-provider="<?php echo esc_attr($binding); ?>"
|
||||
data-user-id="<?php echo esc_attr((string) $user->ID); ?>">
|
||||
<?php _e('解除绑定', 'aksrt'); ?>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<script>
|
||||
(function(){
|
||||
var nonce = <?php echo wp_json_encode($unbind_nonce); ?>;
|
||||
var ajaxUrl = <?php echo wp_json_encode(admin_url('admin-ajax.php')); ?>;
|
||||
document.querySelectorAll('.aksrt-oauth-unbind-btn').forEach(function(btn){
|
||||
btn.addEventListener('click', function(){
|
||||
var msg = <?php echo wp_json_encode(__('确定要解除绑定吗?', 'aksrt')); ?>;
|
||||
if(!window.confirm(msg)) return;
|
||||
var fd = new FormData();
|
||||
fd.append('action', 'aksrt_oauth_unbind');
|
||||
fd.append('nonce', nonce);
|
||||
fd.append('provider', btn.getAttribute('data-provider'));
|
||||
fd.append('userId', btn.getAttribute('data-user-id'));
|
||||
fetch(ajaxUrl, { method: 'POST', credentials: 'same-origin', body: fd })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){ if(d && d.success){ location.reload(); } });
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function ajax_unbind(): void {
|
||||
check_ajax_referer('aksrt_oauth_unbind', 'nonce');
|
||||
$provider = sanitize_key($_POST['provider'] ?? '');
|
||||
$user_id = isset($_POST['userId']) ? (int) $_POST['userId'] : 0;
|
||||
$current_id = get_current_user_id();
|
||||
|
||||
if (empty($provider) || !in_array($provider, ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin', 'baidu', 'alipay'], true)) {
|
||||
wp_send_json_error(400);
|
||||
}
|
||||
|
||||
if ($current_id !== $user_id || !current_user_can('read')) {
|
||||
wp_send_json_error(403);
|
||||
}
|
||||
|
||||
$bindings = get_user_meta($current_id, 'aksrt_oauth_bindings', true) ?: [];
|
||||
$bindings = array_values(array_diff($bindings, [$provider]));
|
||||
update_user_meta($current_id, 'aksrt_oauth_bindings', $bindings);
|
||||
delete_user_meta($current_id, "aksrt_{$provider}_id");
|
||||
delete_user_meta($current_id, "aksrt_{$provider}_token");
|
||||
|
||||
wp_send_json_success(['unbound' => true]);
|
||||
}
|
||||
|
||||
private function find_user_by_provider(string $provider, string $provider_id): ?\WP_User {
|
||||
if (empty($provider_id)) return null;
|
||||
$users = get_users([
|
||||
'meta_key' => "aksrt_{$provider}_id",
|
||||
'meta_value' => $provider_id,
|
||||
'number' => 1,
|
||||
]);
|
||||
return !empty($users[0]) ? $users[0] : null;
|
||||
}
|
||||
|
||||
private function bind_provider_to_user(int $user_id, string $provider, array $user_data): void {
|
||||
if (!empty($user_data['provider_id'])) {
|
||||
update_user_meta($user_id, "aksrt_{$provider}_id", $user_data['provider_id']);
|
||||
}
|
||||
|
||||
$bindings = get_user_meta($user_id, 'aksrt_oauth_bindings', true) ?: [];
|
||||
if (!is_array($bindings)) $bindings = [];
|
||||
if (!in_array($provider, $bindings, true)) {
|
||||
$bindings[] = $provider;
|
||||
update_user_meta($user_id, 'aksrt_oauth_bindings', $bindings);
|
||||
}
|
||||
|
||||
if (!empty($user_data['avatar_url'])) {
|
||||
update_user_meta($user_id, 'aksrt_oauth_avatar', esc_url_raw($user_data['avatar_url']));
|
||||
}
|
||||
}
|
||||
|
||||
private function get_profile_url(): string {
|
||||
if (function_exists('aksrt_get_user_center_url')) {
|
||||
return aksrt_get_user_center_url();
|
||||
}
|
||||
$user = wp_get_current_user();
|
||||
if ($user->ID) {
|
||||
return home_url('/user/' . $user->user_nicename . '/');
|
||||
}
|
||||
return home_url('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class OAuthProxyClient {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
/**
|
||||
* 初始化:注册回调路由
|
||||
*/
|
||||
public function init(): void {
|
||||
add_action('template_redirect', [$this, 'handle_proxy_callback']);
|
||||
add_action('init', [$this, 'add_rewrite_rules']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 URL 重写规则
|
||||
*/
|
||||
public function add_rewrite_rules(): void {
|
||||
add_rewrite_rule(
|
||||
'^oauth/proxy/callback/?$',
|
||||
'index.php?aksrt_proxy_callback=1',
|
||||
'top'
|
||||
);
|
||||
|
||||
add_filter('query_vars', function ($vars) {
|
||||
$vars[] = 'aksrt_proxy_callback';
|
||||
return $vars;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成代理登录 URL
|
||||
*
|
||||
* @param string $provider 平台标识
|
||||
* @param string $action 操作类型: 'login' | 'bind'
|
||||
* @return string 代理登录 URL
|
||||
*/
|
||||
public function get_proxy_auth_url(string $provider, string $action = 'login'): string {
|
||||
$server_url = \AKSRT\Settings\Settings::oauth_proxy_server_url();
|
||||
$secret = \AKSRT\Settings\Settings::oauth_proxy_secret();
|
||||
|
||||
if (empty($server_url) || empty($secret)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$back_url = home_url('/oauth/proxy/callback');
|
||||
$uid = get_current_user_id();
|
||||
$nonce = wp_create_nonce('aksrt_proxy_' . $provider . '_' . $uid);
|
||||
|
||||
$params = [
|
||||
'provider' => $provider,
|
||||
'agent_back_url' => $back_url,
|
||||
'action' => $action,
|
||||
'nonce' => $nonce,
|
||||
'uid' => $uid,
|
||||
'ts' => time(),
|
||||
];
|
||||
|
||||
$params['sign'] = $this->sign($params, $secret);
|
||||
|
||||
$server_url = rtrim($server_url, '/');
|
||||
return $server_url . '/oauth/proxy/' . $provider . '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理代理回调(从服务端重定向回来)
|
||||
*/
|
||||
public function handle_proxy_callback(): void {
|
||||
if (!get_query_var('aksrt_proxy_callback')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$secret = \AKSRT\Settings\Settings::oauth_proxy_secret();
|
||||
if (empty($secret)) {
|
||||
wp_die(__('代理登录配置错误', 'aksrt'));
|
||||
}
|
||||
|
||||
$sign = sanitize_text_field($_GET['sign'] ?? '');
|
||||
$provider = sanitize_key($_GET['provider'] ?? '');
|
||||
$action = sanitize_key($_GET['action'] ?? 'login');
|
||||
|
||||
if (empty($sign) || empty($provider)) {
|
||||
wp_die(__('代理回调参数无效', 'aksrt'));
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
$verify_params = $_GET;
|
||||
unset($verify_params['sign']);
|
||||
|
||||
if (!$this->verify_sign($verify_params, $secret, $sign)) {
|
||||
wp_die(__('代理回调签名验证失败', 'aksrt'));
|
||||
}
|
||||
|
||||
// 验证时间戳(5分钟内有效)
|
||||
$ts = (int) ($_GET['ts'] ?? 0);
|
||||
if (abs(time() - $ts) > 300) {
|
||||
wp_die(__('代理回调已过期,请重新登录', 'aksrt'));
|
||||
}
|
||||
|
||||
// 提取用户数据
|
||||
$provider_id = sanitize_text_field($_GET['provider_id'] ?? '');
|
||||
if (empty($provider_id)) {
|
||||
wp_die(__('代理回调缺少用户标识', 'aksrt'));
|
||||
}
|
||||
|
||||
$user_data = [
|
||||
'provider' => $provider,
|
||||
'provider_id' => $provider_id,
|
||||
'username' => sanitize_user($_GET['username'] ?? '', true),
|
||||
'name' => sanitize_text_field($_GET['name'] ?? ''),
|
||||
'email' => sanitize_email($_GET['email'] ?? ''),
|
||||
'email_verified' => !empty($_GET['email_verified']),
|
||||
'avatar_url' => esc_url_raw($_GET['avatar_url'] ?? ''),
|
||||
'bio' => sanitize_text_field($_GET['bio'] ?? ''),
|
||||
];
|
||||
|
||||
if ($action === 'bind') {
|
||||
$this->handle_proxy_bind($provider, $user_data);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->handle_proxy_login($provider, $user_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理代理登录
|
||||
*/
|
||||
private function handle_proxy_login(string $provider, array $user_data): void {
|
||||
$user = $this->find_or_create_user_from_proxy($provider, $user_data);
|
||||
|
||||
// 未匹配已有账号 → 跳转绑定页
|
||||
if (is_wp_error($user) && $user->get_error_code() === 'oauth_no_match') {
|
||||
$error_data = $user->get_error_data();
|
||||
$obk = 'aksrt_ob_' . wp_generate_password(24, false);
|
||||
set_transient($obk, [
|
||||
'provider' => $error_data['provider'],
|
||||
'provider_id' => $error_data['user_data']['provider_id'] ?? '',
|
||||
'user_data' => $error_data['user_data'],
|
||||
'token' => [],
|
||||
], 15 * MINUTE_IN_SECONDS);
|
||||
|
||||
$bind_url = aksrt_get_bind_url();
|
||||
wp_safe_redirect(add_query_arg('obk', $obk, $bind_url));
|
||||
exit;
|
||||
}
|
||||
|
||||
if (is_wp_error($user)) {
|
||||
wp_die(__('登录失败:', 'aksrt') . esc_html($user->get_error_message()));
|
||||
}
|
||||
|
||||
// 登录成功
|
||||
wp_set_auth_cookie($user->ID, true);
|
||||
$redirect_url = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/');
|
||||
wp_safe_redirect(add_query_arg(['oauth_action' => 'login', 'oauth_bound' => $provider], $redirect_url));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理代理绑定(已登录用户绑定第三方账号)
|
||||
*/
|
||||
private function handle_proxy_bind(string $provider, array $user_data): void {
|
||||
$current_uid = get_current_user_id();
|
||||
if (!$current_uid) {
|
||||
wp_safe_redirect(aksrt_get_login_url());
|
||||
exit;
|
||||
}
|
||||
|
||||
// 检查是否已被其他用户占用
|
||||
$existing = $this->find_user_by_provider_meta($provider, $user_data['provider_id']);
|
||||
if ($existing && (int) $existing->ID !== $current_uid) {
|
||||
wp_die(__('该第三方账号已被其他用户绑定', 'aksrt'));
|
||||
}
|
||||
|
||||
$this->bind_provider_to_user($current_uid, $provider, $user_data);
|
||||
|
||||
$redirect_url = function_exists('aksrt_get_user_center_url')
|
||||
? add_query_arg(['oauth_action' => 'bind', 'oauth_bound' => $provider], aksrt_get_user_center_url('settings'))
|
||||
: home_url('/');
|
||||
wp_safe_redirect($redirect_url);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找或创建用户(代理模式)
|
||||
*
|
||||
* @return \WP_User|\WP_Error
|
||||
*/
|
||||
private function find_or_create_user_from_proxy(string $provider, array $user_data) {
|
||||
$email = $user_data['email'] ?? '';
|
||||
|
||||
// 1. 通过 provider_id 查找
|
||||
if (!empty($user_data['provider_id'])) {
|
||||
$existing = $this->find_user_by_provider_meta($provider, $user_data['provider_id']);
|
||||
if ($existing instanceof \WP_User) {
|
||||
$this->ensure_binding_meta($existing->ID, $provider, $user_data);
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 通过邮箱查找
|
||||
if (!empty($email) && is_email($email)) {
|
||||
$existing = get_user_by('email', $email);
|
||||
if ($existing && !$existing instanceof \WP_Error) {
|
||||
$this->ensure_binding_meta($existing->ID, $provider, $user_data);
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 未匹配
|
||||
return new \WP_Error('oauth_no_match', __('需要绑定已有账号', 'aksrt'), [
|
||||
'provider' => $provider,
|
||||
'user_data' => $user_data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保绑定 meta 存在
|
||||
*/
|
||||
private function ensure_binding_meta(int $user_id, string $provider, array $user_data): void {
|
||||
update_user_meta($user_id, "aksrt_{$provider}_id", $user_data['provider_id'] ?? '');
|
||||
|
||||
$bindings = get_user_meta($user_id, 'aksrt_oauth_bindings', true) ?: [];
|
||||
if (!is_array($bindings)) {
|
||||
$bindings = [];
|
||||
}
|
||||
if (!in_array($provider, $bindings, true)) {
|
||||
$bindings[] = $provider;
|
||||
update_user_meta($user_id, 'aksrt_oauth_bindings', $bindings);
|
||||
}
|
||||
|
||||
if (!empty($user_data['avatar_url'])) {
|
||||
update_user_meta($user_id, 'aksrt_oauth_avatar', esc_url_raw($user_data['avatar_url']));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定第三方账号到用户
|
||||
*/
|
||||
private function bind_provider_to_user(int $user_id, string $provider, array $user_data): void {
|
||||
if (!empty($user_data['provider_id'])) {
|
||||
update_user_meta($user_id, "aksrt_{$provider}_id", $user_data['provider_id']);
|
||||
}
|
||||
|
||||
$bindings = get_user_meta($user_id, 'aksrt_oauth_bindings', true) ?: [];
|
||||
if (!is_array($bindings)) {
|
||||
$bindings = [];
|
||||
}
|
||||
if (!in_array($provider, $bindings, true)) {
|
||||
$bindings[] = $provider;
|
||||
update_user_meta($user_id, 'aksrt_oauth_bindings', $bindings);
|
||||
}
|
||||
|
||||
if (!empty($user_data['avatar_url'])) {
|
||||
update_user_meta($user_id, 'aksrt_oauth_avatar', esc_url_raw($user_data['avatar_url']));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 provider meta 查找用户
|
||||
*/
|
||||
private function find_user_by_provider_meta(string $provider, string $provider_id): ?\WP_User {
|
||||
if (empty($provider_id)) {
|
||||
return null;
|
||||
}
|
||||
$users = get_users([
|
||||
'meta_key' => "aksrt_{$provider}_id",
|
||||
'meta_value' => $provider_id,
|
||||
'number' => 1,
|
||||
]);
|
||||
return !empty($users[0]) ? $users[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 HMAC-SHA256 签名
|
||||
*/
|
||||
private function sign(array $data, string $secret): string {
|
||||
unset($data['sign']);
|
||||
ksort($data);
|
||||
$payload = http_build_query($data);
|
||||
return hash_hmac('sha256', $payload, $secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 HMAC-SHA256 签名
|
||||
*/
|
||||
private function verify_sign(array $data, string $secret, string $expected_sign): bool {
|
||||
$computed = $this->sign($data, $secret);
|
||||
return hash_equals($computed, $expected_sign);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class OAuthProxyServer {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public function init(): void {
|
||||
add_action('template_redirect', [$this, 'handle_proxy_routes'], 5);
|
||||
add_action('init', [$this, 'add_rewrite_rules']);
|
||||
}
|
||||
|
||||
public function add_rewrite_rules(): void {
|
||||
add_rewrite_rule('^oauth/proxy/([a-z]+)/?$', 'index.php?aksrt_proxy_provider=$matches[1]', 'top');
|
||||
add_rewrite_rule('^oauth/proxy/([a-z]+)/callback/?$', 'index.php?aksrt_proxy_provider=$matches[1]&aksrt_proxy_server_callback=1', 'top');
|
||||
add_filter('query_vars', function ($vars) {
|
||||
$vars[] = 'aksrt_proxy_provider';
|
||||
$vars[] = 'aksrt_proxy_server_callback';
|
||||
return $vars;
|
||||
});
|
||||
}
|
||||
|
||||
public function handle_proxy_routes(): void {
|
||||
$provider = sanitize_key(get_query_var('aksrt_proxy_provider'));
|
||||
if (empty($provider)) return;
|
||||
|
||||
if (\AKSRT\Settings\Settings::oauth_proxy_mode() !== 'server') return;
|
||||
|
||||
$allowed = \AKSRT\Settings\Settings::oauth_proxy_allowed_providers();
|
||||
if (!in_array($provider, $allowed, true)) {
|
||||
wp_die(__('不支持的代理登录平台', 'aksrt'));
|
||||
}
|
||||
|
||||
$is_callback = !empty(get_query_var('aksrt_proxy_server_callback'));
|
||||
if ($is_callback) {
|
||||
$this->handle_proxy_callback($provider);
|
||||
} else {
|
||||
$this->handle_proxy_request($provider);
|
||||
}
|
||||
}
|
||||
|
||||
private function handle_proxy_request(string $provider): void {
|
||||
$secret = \AKSRT\Settings\Settings::oauth_proxy_secret();
|
||||
if (empty($secret)) {
|
||||
wp_die(__('代理登录服务端未配置密钥', 'aksrt'));
|
||||
}
|
||||
|
||||
$agent_back_url = esc_url_raw($_GET['agent_back_url'] ?? '');
|
||||
$sign = sanitize_text_field($_GET['sign'] ?? '');
|
||||
$action = sanitize_key($_GET['action'] ?? 'login');
|
||||
|
||||
if (empty($agent_back_url) || empty($sign)) {
|
||||
wp_die(__('代理请求参数不完整', 'aksrt'));
|
||||
}
|
||||
|
||||
$verify_params = $_GET;
|
||||
unset($verify_params['sign']);
|
||||
if (!$this->verify_sign($verify_params, $secret, $sign)) {
|
||||
wp_die(__('代理请求签名验证失败', 'aksrt'));
|
||||
}
|
||||
|
||||
$ts = (int) ($_GET['ts'] ?? 0);
|
||||
if (abs(time() - $ts) > 300) {
|
||||
wp_die(__('代理请求已过期,请重试', 'aksrt'));
|
||||
}
|
||||
|
||||
@session_start();
|
||||
$_SESSION['aksrt_proxy_agent_back_url'] = $agent_back_url;
|
||||
$_SESSION['aksrt_proxy_action'] = $action;
|
||||
$_SESSION['aksrt_proxy_nonce'] = sanitize_text_field($_GET['nonce'] ?? '');
|
||||
$_SESSION['aksrt_proxy_uid'] = (int) ($_GET['uid'] ?? 0);
|
||||
|
||||
try {
|
||||
$oauth = OAuth::get_instance();
|
||||
$auth_url = $oauth->get_auth_url($provider, $action);
|
||||
if (empty($auth_url)) {
|
||||
wp_die(__('无法生成授权链接', 'aksrt'));
|
||||
}
|
||||
wp_safe_redirect($auth_url);
|
||||
exit;
|
||||
} catch (\Exception $e) {
|
||||
wp_die(__('代理登录错误:', 'aksrt') . esc_html($e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private function handle_proxy_callback(string $provider): void {
|
||||
@session_start();
|
||||
|
||||
$agent_back_url = $_SESSION['aksrt_proxy_agent_back_url'] ?? '';
|
||||
if (empty($agent_back_url)) return;
|
||||
|
||||
$_SESSION['aksrt_proxy_agent_back_url'] = '';
|
||||
$_SESSION['aksrt_proxy_action'] = '';
|
||||
$_SESSION['aksrt_proxy_nonce'] = '';
|
||||
$_SESSION['aksrt_proxy_uid'] = 0;
|
||||
|
||||
$secret = \AKSRT\Settings\Settings::oauth_proxy_secret();
|
||||
if (empty($secret)) {
|
||||
wp_die(__('代理登录服务端配置错误', 'aksrt'));
|
||||
}
|
||||
|
||||
$error = $_GET['error'] ?? '';
|
||||
if ($error) {
|
||||
wp_safe_redirect(add_query_arg(['error' => urlencode($error)], $agent_back_url));
|
||||
exit;
|
||||
}
|
||||
|
||||
$code = sanitize_text_field($_GET['code'] ?? '');
|
||||
if (empty($code)) {
|
||||
wp_safe_redirect(add_query_arg(['error' => urlencode('missing_code')], $agent_back_url));
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$user_data = $this->fetch_user_data($provider, $code);
|
||||
|
||||
if (empty($user_data['provider_id'])) {
|
||||
wp_safe_redirect(add_query_arg(['error' => urlencode('no_user_info')], $agent_back_url));
|
||||
exit;
|
||||
}
|
||||
|
||||
$callback_params = [
|
||||
'provider' => $provider,
|
||||
'provider_id' => $user_data['provider_id'],
|
||||
'username' => $user_data['username'] ?? '',
|
||||
'name' => $user_data['name'] ?? '',
|
||||
'email' => $user_data['email'] ?? '',
|
||||
'email_verified' => !empty($user_data['email_verified']) ? '1' : '0',
|
||||
'avatar_url' => $user_data['avatar_url'] ?? '',
|
||||
'bio' => $user_data['bio'] ?? '',
|
||||
'action' => sanitize_key($_SESSION['aksrt_proxy_action'] ?? 'login'),
|
||||
'ts' => time(),
|
||||
];
|
||||
|
||||
$callback_params['sign'] = $this->sign($callback_params, $secret);
|
||||
$redirect_url = $agent_back_url . (strpos($agent_back_url, '?') !== false ? '&' : '?') . http_build_query($callback_params);
|
||||
wp_safe_redirect($redirect_url);
|
||||
exit;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
wp_safe_redirect(add_query_arg(['error' => urlencode($e->getMessage())], $agent_back_url));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户数据
|
||||
*/
|
||||
private function fetch_user_data(string $provider, string $code): array {
|
||||
$config = get_option('aksrt_oauth', [])[$provider] ?? [];
|
||||
if (empty($config['client_id']) || empty($config['client_secret'])) {
|
||||
throw new \Exception(__('未配置此平台的 OAuth 凭据', 'aksrt'));
|
||||
}
|
||||
|
||||
$redirect_uri = !empty($config['redirect_uri'])
|
||||
? $config['redirect_uri']
|
||||
: home_url('/oauth/proxy/' . $provider . '/callback');
|
||||
|
||||
switch ($provider) {
|
||||
case 'github':
|
||||
$oauth = new \Yurun\OAuthLogin\Github\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
$oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null);
|
||||
$userInfo = $oauth->getUserInfo();
|
||||
|
||||
$email = $userInfo['email'] ?? '';
|
||||
if (empty($email)) {
|
||||
$emails_json = $oauth->http->header('Authorization', ' token ' . $oauth->accessToken)
|
||||
->get('https://api.github.com/user/emails')
|
||||
->body();
|
||||
$emails = json_decode($emails_json, true) ?: [];
|
||||
foreach ($emails as $em) {
|
||||
if (!empty($em['primary']) && !empty($em['email'])) {
|
||||
$email = $em['email'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'provider' => 'github',
|
||||
'provider_id' => (string) ($userInfo['id'] ?? ''),
|
||||
'username' => $userInfo['login'] ?? '',
|
||||
'name' => $userInfo['name'] ?? '',
|
||||
'email' => $email,
|
||||
'avatar_url' => $userInfo['avatar_url'] ?? '',
|
||||
'bio' => $userInfo['bio'] ?? '',
|
||||
];
|
||||
|
||||
case 'gitee':
|
||||
$oauth = new \Yurun\OAuthLogin\Gitee\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
$oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null);
|
||||
$userInfo = $oauth->getUserInfo();
|
||||
|
||||
return [
|
||||
'provider' => 'gitee',
|
||||
'provider_id' => (string) ($userInfo['id'] ?? ''),
|
||||
'username' => $userInfo['login'] ?? '',
|
||||
'name' => $userInfo['name'] ?? '',
|
||||
'email' => $userInfo['email'] ?? '',
|
||||
'avatar_url' => $userInfo['avatar_url'] ?? '',
|
||||
'bio' => $userInfo['bio'] ?? '',
|
||||
];
|
||||
|
||||
case 'qq':
|
||||
$oauth = new \Yurun\OAuthLogin\QQ\OAuth2(
|
||||
$config['client_id'],
|
||||
$config['client_secret'],
|
||||
$redirect_uri
|
||||
);
|
||||
$oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null);
|
||||
$openid = $oauth->getOpenID();
|
||||
$userInfo = $oauth->getUserInfo();
|
||||
|
||||
return [
|
||||
'provider' => 'qq',
|
||||
'provider_id' => $openid,
|
||||
'username' => $userInfo['nickname'] ?? '',
|
||||
'name' => $userInfo['nickname'] ?? '',
|
||||
'email' => '',
|
||||
'avatar_url' => $userInfo['figureurl_qq_2'] ?? $userInfo['figureurl_2'] ?? '',
|
||||
];
|
||||
|
||||
case 'google':
|
||||
$response = wp_remote_post('https://oauth2.googleapis.com/token', [
|
||||
'body' => [
|
||||
'code' => $code,
|
||||
'client_id' => $config['client_id'],
|
||||
'client_secret' => $config['client_secret'],
|
||||
'redirect_uri' => $redirect_uri,
|
||||
'grant_type' => 'authorization_code',
|
||||
],
|
||||
]);
|
||||
if (is_wp_error($response)) throw new \Exception($response->get_error_message());
|
||||
$token_data = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (empty($token_data['access_token'])) throw new \Exception(__('Google token 获取失败', 'aksrt'));
|
||||
|
||||
$user_response = wp_remote_get('https://www.googleapis.com/oauth2/v2/userinfo', [
|
||||
'headers' => ['Authorization' => 'Bearer ' . $token_data['access_token']],
|
||||
]);
|
||||
if (is_wp_error($user_response)) throw new \Exception($user_response->get_error_message());
|
||||
$user_info = json_decode(wp_remote_retrieve_body($user_response), true);
|
||||
|
||||
return [
|
||||
'provider' => 'google',
|
||||
'provider_id' => (string) ($user_info['id'] ?? ''),
|
||||
'username' => $user_info['email'] ?? '',
|
||||
'name' => $user_info['name'] ?? '',
|
||||
'email' => $user_info['email'] ?? '',
|
||||
'avatar_url' => $user_info['picture'] ?? '',
|
||||
];
|
||||
|
||||
default:
|
||||
throw new \Exception(__('不支持的 OAuth 平台', 'aksrt'));
|
||||
}
|
||||
}
|
||||
|
||||
private function sign(array $data, string $secret): string {
|
||||
unset($data['sign']);
|
||||
ksort($data);
|
||||
return hash_hmac('sha256', http_build_query($data), $secret);
|
||||
}
|
||||
|
||||
private function verify_sign(array $data, string $secret, string $expected_sign): bool {
|
||||
return hash_equals($this->sign($data, $secret), $expected_sign);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class Router {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public function init(): void {
|
||||
add_filter('query_vars', [$this, 'register_query_vars']);
|
||||
add_action('init', [$this, 'register_rewrite_rules']);
|
||||
add_filter('template_include', [$this, 'handle_template_include'], 99);
|
||||
add_action('init', [$this, 'maybe_flush_rewrite_rules']);
|
||||
|
||||
add_filter('login_url', [$this, 'filter_login_url'], 10, 3);
|
||||
add_filter('lostpassword_url', [$this, 'filter_lostpassword_url'], 10, 2);
|
||||
add_filter('register_url', [$this, 'filter_register_url']);
|
||||
add_filter('logout_url', [$this, 'filter_logout_url'], 10, 2);
|
||||
|
||||
add_action('login_init', [$this, 'redirect_native_login']);
|
||||
add_action('template_redirect', [$this, 'handle_logout_route']);
|
||||
}
|
||||
|
||||
public function register_query_vars(array $vars): array {
|
||||
$vars[] = 'aksrt_sign';
|
||||
$vars[] = 'aksrt_auth_tab';
|
||||
$vars[] = 'aksrt_logout';
|
||||
return $vars;
|
||||
}
|
||||
|
||||
private function auth_route_map(): array {
|
||||
return [
|
||||
'login' => 'index.php?aksrt_sign=1&aksrt_auth_tab=login',
|
||||
'register' => 'index.php?aksrt_sign=1&aksrt_auth_tab=register',
|
||||
'bind-account' => 'index.php?aksrt_sign=1&aksrt_auth_tab=bind',
|
||||
'logout' => 'index.php?aksrt_logout=1',
|
||||
'reset-password' => 'index.php?aksrt_sign=1&aksrt_auth_tab=reset-password',
|
||||
'verify-email' => 'index.php?aksrt_sign=1&aksrt_auth_tab=verify-email',
|
||||
];
|
||||
}
|
||||
|
||||
public function register_rewrite_rules(): void {
|
||||
foreach ($this->auth_route_map() as $path => $query) {
|
||||
add_rewrite_rule('^' . preg_quote($path, '/') . '/?$', $query, 'top');
|
||||
}
|
||||
}
|
||||
|
||||
public function handle_template_include(string $template): string {
|
||||
if (get_query_var('aksrt_sign')) {
|
||||
// §4.1 Global sign gate: when close_sign is active, block non-logged-in access to sign page
|
||||
if (class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::close_sign() && !is_user_logged_in()) {
|
||||
$tab = (string) get_query_var('aksrt_auth_tab');
|
||||
if (!in_array($tab, ['reset-password', 'verify-email', 'bind-account'], true) && empty($_REQUEST['interim-login'])) {
|
||||
if (!headers_sent()) {
|
||||
wp_safe_redirect(home_url('/'), 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$auth_template = function_exists('aksrt_get_auth_template_file')
|
||||
? aksrt_get_auth_template_file((string) get_query_var('aksrt_auth_tab'), true)
|
||||
: 'templates/pages/page-sign.php';
|
||||
$tpl = aksrt_locate_template_file($auth_template);
|
||||
if ($tpl) {
|
||||
return $tpl;
|
||||
}
|
||||
}
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
public function maybe_flush_rewrite_rules(): void {
|
||||
$rewrite_version = AKSRT_VERSION . ':sign-router-v2-logout';
|
||||
if (get_option('aksrt_auth_routes_flushed') !== $rewrite_version) {
|
||||
flush_rewrite_rules(false);
|
||||
update_option('aksrt_auth_routes_flushed', $rewrite_version);
|
||||
}
|
||||
}
|
||||
|
||||
public function filter_login_url(string $login_url, string $redirect, bool $force_reauth): string {
|
||||
if (strpos($login_url, 'wp-login.php') === false) {
|
||||
return $login_url;
|
||||
}
|
||||
|
||||
$custom_login_url = aksrt_get_login_url();
|
||||
if ($redirect !== '') {
|
||||
$custom_login_url = add_query_arg('redirect_to', urlencode($redirect), $custom_login_url);
|
||||
}
|
||||
if ($force_reauth) {
|
||||
$custom_login_url = add_query_arg('reauth', '1', $custom_login_url);
|
||||
}
|
||||
|
||||
return $custom_login_url;
|
||||
}
|
||||
|
||||
public function filter_lostpassword_url(string $lostpassword_url, string $redirect): string {
|
||||
$custom_url = aksrt_get_reset_password_url();
|
||||
if ($redirect !== '') {
|
||||
$custom_url = add_query_arg('redirect_to', urlencode($redirect), $custom_url);
|
||||
}
|
||||
return $custom_url;
|
||||
}
|
||||
|
||||
public function filter_register_url(string $register_url): string {
|
||||
return aksrt_get_register_url();
|
||||
}
|
||||
|
||||
public function filter_logout_url(string $logout_url, string $redirect): string {
|
||||
return aksrt_get_logout_url($redirect ?: home_url('/'));
|
||||
}
|
||||
|
||||
public function redirect_native_login(): void {
|
||||
if (wp_doing_ajax()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// §4.1 Emergency bypass key (admin_sign_key): allow wp-login.php direct access when key matches
|
||||
if (isset($_GET['aksrt_bypass'])) {
|
||||
$key = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::admin_sign_key() : '';
|
||||
if ($key !== '' && hash_equals($key, (string) $_GET['aksrt_bypass'])) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$action = isset($_REQUEST['action']) ? sanitize_key((string) $_REQUEST['action']) : 'login';
|
||||
$redirect_to = isset($_REQUEST['redirect_to']) ? wp_unslash((string) $_REQUEST['redirect_to']) : '';
|
||||
$interim = !empty($_REQUEST['interim-login']);
|
||||
$reauth = !empty($_REQUEST['reauth']);
|
||||
$is_admin_target = $redirect_to !== '' && strpos($redirect_to, admin_url()) === 0;
|
||||
|
||||
if (is_user_logged_in() && current_user_can('manage_options')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($is_admin_target || $interim || $reauth || $action === 'logout') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($action) {
|
||||
case 'register':
|
||||
$target = aksrt_get_register_url();
|
||||
break;
|
||||
case 'lostpassword':
|
||||
$target = aksrt_get_reset_password_url();
|
||||
break;
|
||||
case 'rp':
|
||||
case 'resetpass':
|
||||
$target = add_query_arg('legacy', '1', aksrt_get_reset_password_url());
|
||||
break;
|
||||
default:
|
||||
$target = aksrt_get_login_url();
|
||||
break;
|
||||
}
|
||||
|
||||
$passthrough = [];
|
||||
if ($redirect_to !== '') {
|
||||
$passthrough['redirect_to'] = $redirect_to;
|
||||
}
|
||||
if ($reauth) {
|
||||
$passthrough['reauth'] = '1';
|
||||
}
|
||||
if (!empty($_REQUEST['obk'])) {
|
||||
$passthrough['obk'] = sanitize_text_field((string) $_REQUEST['obk']);
|
||||
}
|
||||
|
||||
if (!empty($passthrough)) {
|
||||
$target = add_query_arg($passthrough, $target);
|
||||
}
|
||||
|
||||
wp_safe_redirect($target, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function handle_logout_route(): void {
|
||||
if (!$this->is_logout_request()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_user_logged_in()) {
|
||||
wp_safe_redirect(home_url('/'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$nonce = isset($_GET['_wpnonce']) ? sanitize_text_field((string) $_GET['_wpnonce']) : '';
|
||||
// Accept theme nonce and core log-out nonce (wp_logout_url default).
|
||||
$nonce_ok = ($nonce !== '' && (
|
||||
wp_verify_nonce($nonce, 'aksrt-logout')
|
||||
|| wp_verify_nonce($nonce, 'log-out')
|
||||
));
|
||||
if (!$nonce_ok) {
|
||||
wp_die(esc_html__('退出登录请求无效,请重试。', 'aksrt'), esc_html__('退出登录', 'aksrt'), ['response' => 403]);
|
||||
}
|
||||
|
||||
$redirect_to = isset($_GET['redirect_to']) ? wp_unslash((string) $_GET['redirect_to']) : home_url('/');
|
||||
$redirect_to = wp_validate_redirect($redirect_to, home_url('/'));
|
||||
|
||||
wp_logout();
|
||||
wp_safe_redirect($redirect_to !== '' ? $redirect_to : home_url('/'));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect logout via rewrite query var, GET flag, or pretty path.
|
||||
*/
|
||||
private function is_logout_request(): bool {
|
||||
if ((string) get_query_var('aksrt_logout') !== '' && (string) get_query_var('aksrt_logout') !== '0') {
|
||||
return true;
|
||||
}
|
||||
if (isset($_GET['aksrt_logout']) && (string) $_GET['aksrt_logout'] !== '' && (string) $_GET['aksrt_logout'] !== '0') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$request_uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';
|
||||
if ($request_uri === '') {
|
||||
return false;
|
||||
}
|
||||
$path = (string) wp_parse_url($request_uri, PHP_URL_PATH);
|
||||
$home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH);
|
||||
if ($home_path !== '' && $home_path !== '/' && str_starts_with($path, $home_path)) {
|
||||
$path = (string) substr($path, strlen(rtrim($home_path, '/')));
|
||||
}
|
||||
$path = '/' . trim($path, '/');
|
||||
return $path === '/logout';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class Service {
|
||||
use ServicePart1a;
|
||||
use ServicePart1b;
|
||||
|
||||
|
||||
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\Credits\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 等所有
|
||||
* 不经过 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\Settings') || !\AKSRT\Settings\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 (!EmailVerification::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 (EmailVerification::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\Settings')) {
|
||||
return 'link';
|
||||
}
|
||||
|
||||
$method = \AKSRT\Settings\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,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ServicePart1a {
|
||||
|
||||
|
||||
public static function register_user(array $post, array $context = []): array {
|
||||
// 先 reset pending invite context,避免同一进程内残留污染
|
||||
self::$pending_invite_inviter_id = 0;
|
||||
self::$pending_invite_code = '';
|
||||
|
||||
$user_login = sanitize_user(wp_unslash($post['user_login'] ?? ''), true);
|
||||
$user_email = sanitize_email(wp_unslash($post['user_email'] ?? ''));
|
||||
$password = (string) ($post['user_pass'] ?? '');
|
||||
$confirm = (string) ($post['user_pass_confirm'] ?? '');
|
||||
|
||||
if (!wp_verify_nonce($post['_register_nonce'] ?? '', 'aksrt_register')) {
|
||||
return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')];
|
||||
}
|
||||
|
||||
if (empty($post['agree_terms'])) {
|
||||
return ['success' => false, 'error' => __('请先同意服务条款', 'aksrt')];
|
||||
}
|
||||
|
||||
if (empty($user_login) || !validate_username($user_login)) {
|
||||
return ['success' => false, 'error' => __('请输入有效的用户名', 'aksrt')];
|
||||
}
|
||||
|
||||
if (mb_strlen($user_login) < 3 || mb_strlen($user_login) > 60) {
|
||||
return ['success' => false, 'error' => __('用户名长度需在 3-60 个字符之间', 'aksrt')];
|
||||
}
|
||||
|
||||
if (username_exists($user_login)) {
|
||||
return ['success' => false, 'error' => __('该用户名已被使用', 'aksrt')];
|
||||
}
|
||||
|
||||
if (empty($user_email) || !is_email($user_email)) {
|
||||
return ['success' => false, 'error' => __('请输入有效的邮箱地址', 'aksrt')];
|
||||
}
|
||||
|
||||
if (email_exists($user_email)) {
|
||||
return ['success' => false, 'error' => __('该邮箱已被其他账号使用', 'aksrt')];
|
||||
}
|
||||
|
||||
$password_check = self::validate_password($password, $confirm);
|
||||
if ($password_check !== true) {
|
||||
return ['success' => false, 'error' => $password_check];
|
||||
}
|
||||
|
||||
if (function_exists('aksrt_require_captcha')) {
|
||||
$captcha_result = aksrt_require_captcha('register', $post);
|
||||
if ($captcha_result !== true) {
|
||||
return ['success' => false, 'error' => $captcha_result];
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 解析邀请码 ======
|
||||
// 优先级:表单 invite_code(用户显式填写) > cookie aksrt_invite
|
||||
$invite_registration_enabled = class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::invite_registration_enabled();
|
||||
$invite_mode = $invite_registration_enabled && class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::invite_register_field_mode()
|
||||
: 'hidden';
|
||||
$invite_code = isset($post['invite_code']) ? sanitize_key((string) wp_unslash($post['invite_code'])) : '';
|
||||
$resolved_inviter_id = 0;
|
||||
$invite_is_valid = false;
|
||||
|
||||
if (!$invite_registration_enabled) {
|
||||
// 邀请功能关闭,忽略所有邀请码
|
||||
$invite_code = '';
|
||||
} elseif ($invite_code !== '') {
|
||||
if (class_exists('\AKSRT\Credits\Invite')) {
|
||||
$invite_service = \AKSRT\Credits\Invite::get_instance();
|
||||
$resolved_inviter_id = $invite_service->resolve_code($invite_code);
|
||||
$invite_is_valid = $invite_service->validate_code($invite_code);
|
||||
}
|
||||
// 必填模式下,邀请码无效时根据后台开关决定是否拒绝
|
||||
$reject_invalid = class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::invite_reject_invalid();
|
||||
if ($invite_mode === 'required' && $reject_invalid && !$invite_is_valid) {
|
||||
return ['success' => false, 'error' => __('邀请码无效,请检查后重试', 'aksrt')];
|
||||
}
|
||||
} elseif ($invite_mode === 'required') {
|
||||
return ['success' => false, 'error' => __('请填写邀请码', 'aksrt')];
|
||||
}
|
||||
|
||||
// 把解析到的 inviter_id 暂存到静态字段,user_register hook 内部读取(优先于 cookie)
|
||||
self::$pending_invite_inviter_id = $resolved_inviter_id;
|
||||
self::$pending_invite_code = $invite_code;
|
||||
|
||||
$user_id = wp_create_user($user_login, $password, $user_email);
|
||||
if (is_wp_error($user_id)) {
|
||||
self::$pending_invite_inviter_id = 0;
|
||||
self::$pending_invite_code = '';
|
||||
return ['success' => false, 'error' => $user_id->get_error_message()];
|
||||
}
|
||||
|
||||
if ($invite_code !== '' && $resolved_inviter_id <= 0 && class_exists('\AKSRT\Credits\Invite')) {
|
||||
\AKSRT\Credits\Invite::get_instance()->handle_registration_invite((int) $user_id, $invite_code);
|
||||
}
|
||||
|
||||
// wp_create_user 内部已触发 user_register hook,\AKSRT\Credits\Invite::on_user_register 已读取 pending 字段。
|
||||
// 这里清空,避免污染后续请求。
|
||||
self::$pending_invite_inviter_id = 0;
|
||||
self::$pending_invite_code = '';
|
||||
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user || is_wp_error($user)) {
|
||||
return ['success' => false, 'error' => __('注册成功,但读取用户信息失败,请联系管理员', 'aksrt')];
|
||||
}
|
||||
|
||||
$oauth_context = isset($context['oauth_bind_data']) && is_array($context['oauth_bind_data']) ? $context['oauth_bind_data'] : [];
|
||||
$oauth_verified = self::oauth_email_is_verified($oauth_context);
|
||||
$requires_verify = \AKSRT\Settings\Settings::registration_requires_email_verification() && !$oauth_verified;
|
||||
|
||||
if (!$requires_verify) {
|
||||
self::mark_email_verified($user_id);
|
||||
return [
|
||||
'success' => true,
|
||||
'user_id' => (int) $user_id,
|
||||
'user' => $user,
|
||||
'user_login' => $user_login,
|
||||
];
|
||||
}
|
||||
|
||||
$method = \AKSRT\Settings\Settings::registration_verification_method();
|
||||
$begin = self::begin_email_verification(
|
||||
$user,
|
||||
$method,
|
||||
[
|
||||
'obk' => sanitize_text_field((string) ($context['obk'] ?? '')),
|
||||
]
|
||||
);
|
||||
|
||||
if (empty($begin['success'])) {
|
||||
self::delete_user_account($user_id);
|
||||
return $begin;
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'user_id' => (int) $user_id,
|
||||
'user' => $user,
|
||||
'user_login' => $user_login,
|
||||
'verification_required' => true,
|
||||
'verification_method' => $method,
|
||||
'verify_url' => self::get_email_verification_url((int) $user_id, ['mode' => $method]),
|
||||
'message' => $begin['message'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function bind_oauth_account(array $post, array $oauth_data): array {
|
||||
if (!wp_verify_nonce($post['_bind_nonce'] ?? '', 'aksrt_bind')) {
|
||||
return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')];
|
||||
}
|
||||
|
||||
if (function_exists('aksrt_require_captcha')) {
|
||||
$captcha_result = aksrt_require_captcha('bind', $post);
|
||||
if ($captcha_result !== true) {
|
||||
return ['success' => false, 'error' => $captcha_result];
|
||||
}
|
||||
}
|
||||
|
||||
$creds = [
|
||||
'user_login' => self::resolve_email_to_username((string) ($post['log'] ?? '')),
|
||||
'user_password' => (string) ($post['pwd'] ?? ''),
|
||||
'remember' => true,
|
||||
];
|
||||
|
||||
$user = wp_signon($creds);
|
||||
if (is_wp_error($user)) {
|
||||
return ['success' => false, 'error' => self::friendly_login_error($user->get_error_message())];
|
||||
}
|
||||
|
||||
if (self::user_requires_email_verification($user)) {
|
||||
wp_logout();
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => __('该账号尚未完成邮箱验证,请先完成验证后再绑定第三方登录。', 'aksrt'),
|
||||
'verify_url' => self::get_email_verification_url((int) $user->ID),
|
||||
];
|
||||
}
|
||||
|
||||
$provider = sanitize_key((string) ($oauth_data['provider'] ?? ''));
|
||||
if ($provider === '') {
|
||||
return ['success' => false, 'error' => __('绑定数据无效,请重新使用第三方登录', 'aksrt')];
|
||||
}
|
||||
|
||||
$uid = (int) $user->ID;
|
||||
update_user_meta($uid, "aksrt_{$provider}_id", $oauth_data['provider_id'] ?? '');
|
||||
|
||||
$bindings = get_user_meta($uid, 'aksrt_oauth_bindings', true) ?: [];
|
||||
if (!is_array($bindings)) {
|
||||
$bindings = [];
|
||||
}
|
||||
if (!in_array($provider, $bindings, true)) {
|
||||
$bindings[] = $provider;
|
||||
}
|
||||
update_user_meta($uid, 'aksrt_oauth_bindings', $bindings);
|
||||
|
||||
if (!empty($oauth_data['user_data']['avatar_url'])) {
|
||||
update_user_meta($uid, 'aksrt_oauth_avatar', esc_url_raw($oauth_data['user_data']['avatar_url']));
|
||||
}
|
||||
|
||||
if (!empty($post['obk'])) {
|
||||
delete_transient(sanitize_text_field((string) $post['obk']));
|
||||
}
|
||||
|
||||
wp_set_auth_cookie($uid, true);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'user' => $user,
|
||||
'provider' => $provider,
|
||||
];
|
||||
}
|
||||
|
||||
public static function finalize_oauth_registration(int $user_id, array $oauth_data, string $obk = ''): void {
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = sanitize_key((string) ($oauth_data['provider'] ?? ''));
|
||||
if ($provider === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
update_user_meta($user_id, "aksrt_{$provider}_id", $oauth_data['provider_id'] ?? '');
|
||||
update_user_meta($user_id, 'aksrt_oauth_bindings', [$provider]);
|
||||
|
||||
if (!empty($oauth_data['user_data']['avatar_url'])) {
|
||||
update_user_meta($user_id, 'aksrt_oauth_avatar', esc_url_raw($oauth_data['user_data']['avatar_url']));
|
||||
}
|
||||
|
||||
if (self::oauth_email_is_verified($oauth_data)) {
|
||||
EmailVerification::mark_email_verified($user_id);
|
||||
}
|
||||
|
||||
if ($obk !== '') {
|
||||
delete_transient(sanitize_text_field($obk));
|
||||
}
|
||||
|
||||
wp_set_auth_cookie($user_id, true);
|
||||
}
|
||||
|
||||
public static function get_email_verification_context(array $query): array {
|
||||
$invalid_payload = [
|
||||
'mode' => 'invalid',
|
||||
'user' => null,
|
||||
'error' => __('验证入口无效或已过期,请重新发起验证流程。', 'aksrt'),
|
||||
'method' => '',
|
||||
'email' => '',
|
||||
'uid' => 0,
|
||||
'verified' => false,
|
||||
'access_key' => '',
|
||||
];
|
||||
|
||||
$user_id = isset($query['uid']) ? absint($query['uid']) : 0;
|
||||
if ($user_id <= 0) {
|
||||
return $invalid_payload;
|
||||
}
|
||||
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user || is_wp_error($user)) {
|
||||
return $invalid_payload;
|
||||
}
|
||||
|
||||
$method = self::current_registration_verification_method();
|
||||
$email = (string) get_user_meta($user_id, self::EMAIL_TARGET_META, true);
|
||||
$token = sanitize_text_field((string) ($query['token'] ?? ''));
|
||||
$access = sanitize_text_field((string) ($query['vk'] ?? ''));
|
||||
|
||||
// 容错:meta 缺失时(例如历史账号被强制要求验证),回退到设置默认值与 user_email
|
||||
if (!in_array($method, ['link', 'code'], true)) {
|
||||
$method = self::current_registration_verification_method();
|
||||
}
|
||||
if ($email === '' || !is_email($email)) {
|
||||
$email = (string) $user->user_email;
|
||||
}
|
||||
$masked_email = self::masked_email($email);
|
||||
$has_access = self::has_valid_email_access_key($user_id, $access);
|
||||
|
||||
// token 路径:链接模式直接走 token 校验;不依赖 vk
|
||||
if ($token !== '') {
|
||||
if (EmailVerification::is_email_verified($user_id)) {
|
||||
return [
|
||||
'mode' => 'invalid',
|
||||
'user' => $user,
|
||||
'error' => __('该验证链接已失效或已使用,请重新发起验证流程', 'aksrt'),
|
||||
'method' => $has_access ? $method : '',
|
||||
'email' => $has_access ? $masked_email : '',
|
||||
'uid' => $has_access ? $user_id : 0,
|
||||
'verified' => true,
|
||||
'access_key' => $has_access ? $access : '',
|
||||
];
|
||||
}
|
||||
|
||||
$result = self::verify_email_token($user_id, $token);
|
||||
if (!empty($result['success'])) {
|
||||
return [
|
||||
'mode' => 'completed',
|
||||
'user' => $user,
|
||||
'error' => '',
|
||||
'method' => $method,
|
||||
'email' => $masked_email,
|
||||
'uid' => $user_id,
|
||||
'verified' => true,
|
||||
'redirect' => $result['redirect'] ?? home_url('/'),
|
||||
'message' => $result['message'] ?? '',
|
||||
'access_key' => $has_access ? $access : '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'mode' => 'invalid',
|
||||
'user' => $user,
|
||||
'error' => $result['error'] ?? __('验证链接无效或已过期', 'aksrt'),
|
||||
'method' => $has_access ? $method : '',
|
||||
'email' => $has_access ? $masked_email : '',
|
||||
'uid' => $has_access ? $user_id : 0,
|
||||
'verified' => false,
|
||||
'access_key' => $has_access ? $access : '',
|
||||
];
|
||||
}
|
||||
|
||||
// 没有 token 也没有 vk:返回完全脱敏的通用错误,避免根据 uid 枚举账号状态
|
||||
if (!$has_access) {
|
||||
return $invalid_payload;
|
||||
}
|
||||
|
||||
if (!self::user_requires_email_verification($user)) {
|
||||
return [
|
||||
'mode' => 'verified',
|
||||
'user' => $user,
|
||||
'error' => '',
|
||||
'method' => '',
|
||||
'email' => self::masked_email((string) $user->user_email),
|
||||
'uid' => $user_id,
|
||||
'verified' => true,
|
||||
'access_key' => $access,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'mode' => $method === 'code' ? 'code' : 'pending',
|
||||
'user' => $user,
|
||||
'error' => '',
|
||||
'method' => $method,
|
||||
'email' => $masked_email ?: self::masked_email((string) $user->user_email),
|
||||
'uid' => $user_id,
|
||||
'verified' => false,
|
||||
'access_key' => $access,
|
||||
];
|
||||
}
|
||||
|
||||
public static function verify_email_code_submission(array $post): array {
|
||||
if (!wp_verify_nonce($post['_verify_email_nonce'] ?? '', 'aksrt_verify_email')) {
|
||||
return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')];
|
||||
}
|
||||
|
||||
$user_id = isset($post['verify_uid']) ? absint($post['verify_uid']) : 0;
|
||||
$access = sanitize_text_field((string) ($post['vk'] ?? ''));
|
||||
$code = sanitize_text_field((string) ($post['email_code'] ?? ''));
|
||||
if ($user_id <= 0 || $code === '') {
|
||||
return ['success' => false, 'error' => __('请输入完整的邮箱验证码', 'aksrt')];
|
||||
}
|
||||
|
||||
if (!self::has_valid_email_access_key($user_id, $access)) {
|
||||
return ['success' => false, 'error' => __('验证入口无效或已过期,请重新发起验证流程。', 'aksrt')];
|
||||
}
|
||||
|
||||
if (function_exists('aksrt_require_captcha')) {
|
||||
$captcha_result = aksrt_require_captcha('verify_email', $post);
|
||||
if ($captcha_result !== true) {
|
||||
return ['success' => false, 'error' => $captcha_result];
|
||||
}
|
||||
}
|
||||
|
||||
return self::verify_email_code($user_id, $code);
|
||||
}
|
||||
|
||||
public static function resend_email_verification(array $post): array {
|
||||
if (!wp_verify_nonce($post['_verify_email_nonce'] ?? '', 'aksrt_verify_email')) {
|
||||
return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')];
|
||||
}
|
||||
|
||||
$user_id = isset($post['verify_uid']) ? absint($post['verify_uid']) : 0;
|
||||
$access = sanitize_text_field((string) ($post['vk'] ?? ''));
|
||||
|
||||
// 必须先持有有效 vk 才允许重发,避免未登录用户用 uid 枚举触发邮件
|
||||
if ($user_id <= 0 || !self::has_valid_email_access_key($user_id, $access)) {
|
||||
return ['success' => false, 'error' => __('验证入口无效或已过期,请重新发起验证流程。', 'aksrt')];
|
||||
}
|
||||
|
||||
// IP 节流:每 IP 每 RESEND_IP_WINDOW 秒最多 RESEND_IP_MAX 次(跨账号合并计数)
|
||||
$ip = sanitize_text_field((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
if ($ip !== '') {
|
||||
$ip_key = 'aksrt_email_resend_ip_' . md5($ip);
|
||||
$ip_attempts = (int) get_transient($ip_key);
|
||||
if ($ip_attempts >= self::RESEND_IP_MAX) {
|
||||
self::log_event('verify.resend.ip_throttled', ['uid' => $user_id, 'ip' => $ip]);
|
||||
return ['success' => false, 'error' => __('操作过于频繁,请稍后再试', 'aksrt')];
|
||||
}
|
||||
set_transient($ip_key, $ip_attempts + 1, self::RESEND_IP_WINDOW);
|
||||
}
|
||||
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user || is_wp_error($user)) {
|
||||
return ['success' => false, 'error' => __('验证入口无效或已过期,请重新发起验证流程。', 'aksrt')];
|
||||
}
|
||||
|
||||
if (!self::user_requires_email_verification($user)) {
|
||||
return ['success' => false, 'error' => __('该账号已完成邮箱验证,无需重复发送', 'aksrt')];
|
||||
}
|
||||
|
||||
if (function_exists('aksrt_require_captcha')) {
|
||||
$captcha_result = aksrt_require_captcha('verify_email', $post);
|
||||
if ($captcha_result !== true) {
|
||||
return ['success' => false, 'error' => $captcha_result];
|
||||
}
|
||||
}
|
||||
|
||||
$method = self::current_registration_verification_method();
|
||||
$email = (string) get_user_meta($user_id, self::EMAIL_TARGET_META, true);
|
||||
|
||||
// 容错:meta 缺失时回退到设置默认值与 user_email(兼容历史账号 / meta 被外部清理的情况)
|
||||
if (!in_array($method, ['link', 'code'], true)) {
|
||||
$method = \AKSRT\Settings\Settings::registration_verification_method();
|
||||
}
|
||||
if ($email === '' || !is_email($email)) {
|
||||
$email = (string) $user->user_email;
|
||||
}
|
||||
if ($email === '' || !is_email($email)) {
|
||||
return ['success' => false, 'error' => __('当前账号缺少有效邮箱,请联系管理员', 'aksrt')];
|
||||
}
|
||||
|
||||
$requested_at = (int) get_user_meta($user_id, self::EMAIL_REQUESTED_AT_META, true);
|
||||
if ($requested_at > 0 && (time() - $requested_at) < 60) {
|
||||
return ['success' => false, 'error' => __('操作过于频繁,请稍后再试', 'aksrt')];
|
||||
}
|
||||
|
||||
return self::begin_email_verification($user, $method, [
|
||||
'email' => $email,
|
||||
'obk' => (string) get_user_meta($user_id, self::EMAIL_OBK_META, true),
|
||||
'force' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function user_requires_email_verification($user): bool {
|
||||
return EmailVerification::user_requires_email_verification($user);
|
||||
}
|
||||
|
||||
private static function mark_email_verified(int $user_id): void {
|
||||
EmailVerification::mark_email_verified($user_id);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 公开 API:管理员后台 / 内部业务调用(委托 EmailVerification)
|
||||
// ============================================================
|
||||
|
||||
public static function user_is_email_verified(int $user_id): bool {
|
||||
return EmailVerification::user_is_email_verified($user_id);
|
||||
}
|
||||
|
||||
public static function get_user_verification_state(int $user_id): array {
|
||||
return EmailVerification::get_user_verification_state($user_id);
|
||||
}
|
||||
|
||||
public static function admin_mark_verified(int $user_id, int $by_admin_id = 0): array {
|
||||
return EmailVerification::admin_mark_verified($user_id, $by_admin_id);
|
||||
}
|
||||
|
||||
public static function admin_unmark_verified(int $user_id, int $by_admin_id = 0): array {
|
||||
return EmailVerification::admin_unmark_verified($user_id, $by_admin_id);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ServicePart1b {
|
||||
|
||||
public static function admin_send_verification(int $user_id, int $by_admin_id = 0): array {
|
||||
return EmailVerification::admin_send_verification($user_id, $by_admin_id);
|
||||
}
|
||||
|
||||
public static function backfill_legacy_users_as_verified(bool $force = false): int {
|
||||
return EmailVerification::backfill_legacy_users_as_verified($force);
|
||||
}
|
||||
|
||||
public static function run_async_backfill_batch(int $offset = 0): void {
|
||||
EmailVerification::run_async_backfill_batch($offset);
|
||||
}
|
||||
|
||||
private static function delete_user_account(int $user_id): void {
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
if (!function_exists('wp_delete_user')) {
|
||||
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||||
}
|
||||
if (function_exists('wp_delete_user')) {
|
||||
wp_delete_user($user_id);
|
||||
}
|
||||
}
|
||||
|
||||
private static function oauth_email_is_verified(array $oauth_data): bool {
|
||||
$user_data = isset($oauth_data['user_data']) && is_array($oauth_data['user_data']) ? $oauth_data['user_data'] : $oauth_data;
|
||||
if (empty($user_data['email'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($user_data['email_verified'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$provider = sanitize_key((string) ($user_data['provider'] ?? $oauth_data['provider'] ?? ''));
|
||||
if ($provider === 'google' && !empty($user_data['email'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($provider === 'github' && isset($user_data['email_verified'])) {
|
||||
return !empty($user_data['email_verified']);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function begin_email_verification(\WP_User $user, string $method, array $args = []): array {
|
||||
return EmailVerification::begin_email_verification($user, $method, $args);
|
||||
}
|
||||
|
||||
private static function verify_email_token(int $user_id, string $token): array {
|
||||
return EmailVerification::verify_email_token($user_id, $token);
|
||||
}
|
||||
|
||||
private static function verify_email_code(int $user_id, string $code): array {
|
||||
return EmailVerification::verify_email_code($user_id, $code);
|
||||
}
|
||||
|
||||
public static function get_reset_context(array $query): array {
|
||||
if (!empty($query['key']) || !empty($query['login']) || !empty($query['legacy'])) {
|
||||
return [
|
||||
'mode' => 'invalid',
|
||||
'user' => null,
|
||||
'error' => __('旧的重置密码链接已失效,请重新申请重置密码。', 'aksrt'),
|
||||
'token' => '',
|
||||
'user_id'=> 0,
|
||||
];
|
||||
}
|
||||
|
||||
$token = sanitize_text_field($query['token'] ?? '');
|
||||
$user_id = isset($query['uid']) ? absint($query['uid']) : 0;
|
||||
|
||||
if ($token === '' || $user_id <= 0) {
|
||||
return [
|
||||
'mode' => 'request',
|
||||
'user' => null,
|
||||
'error' => '',
|
||||
'token' => '',
|
||||
'user_id'=> 0,
|
||||
];
|
||||
}
|
||||
|
||||
$user = self::validate_reset_token($user_id, $token);
|
||||
if (is_wp_error($user)) {
|
||||
return [
|
||||
'mode' => 'invalid',
|
||||
'user' => null,
|
||||
'error' => $user->get_error_message(),
|
||||
'token' => $token,
|
||||
'user_id'=> $user_id,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'mode' => 'reset',
|
||||
'user' => $user,
|
||||
'error' => '',
|
||||
'token' => $token,
|
||||
'user_id'=> $user_id,
|
||||
];
|
||||
}
|
||||
|
||||
public static function request_password_reset(array $post): array {
|
||||
if (!wp_verify_nonce($post['_reset_nonce'] ?? '', 'aksrt_reset_password')) {
|
||||
return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')];
|
||||
}
|
||||
|
||||
if (function_exists('aksrt_require_captcha')) {
|
||||
$captcha_result = aksrt_require_captcha('lostpassword', $post);
|
||||
if ($captcha_result !== true) {
|
||||
return ['success' => false, 'error' => $captcha_result];
|
||||
}
|
||||
}
|
||||
|
||||
$raw_login = trim((string) ($post['user_login'] ?? ''));
|
||||
if ($raw_login === '') {
|
||||
return ['success' => false, 'error' => __('请输入邮箱地址或用户名', 'aksrt')];
|
||||
}
|
||||
|
||||
$user = null;
|
||||
if (is_email($raw_login)) {
|
||||
$user = get_user_by('email', $raw_login);
|
||||
} else {
|
||||
$user = get_user_by('login', sanitize_user($raw_login, true));
|
||||
}
|
||||
|
||||
if (!$user || is_wp_error($user)) {
|
||||
return ['success' => true, 'message' => __('如果该账号存在,重置邮件已发送到注册邮箱', 'aksrt')];
|
||||
}
|
||||
|
||||
$token = self::create_reset_token($user->ID);
|
||||
$sent = self::send_password_reset_email($user, $token);
|
||||
|
||||
if (!$sent) {
|
||||
return ['success' => false, 'error' => __('重置邮件发送失败,请稍后再试', 'aksrt')];
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => __('如果该账号存在,重置邮件已发送到注册邮箱', 'aksrt')];
|
||||
}
|
||||
|
||||
public static function reset_password_with_token(array $post): array {
|
||||
if (!wp_verify_nonce($post['_setpw_nonce'] ?? '', 'aksrt_set_password')) {
|
||||
return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')];
|
||||
}
|
||||
|
||||
$token = sanitize_text_field($post['reset_token'] ?? '');
|
||||
$user_id = isset($post['reset_uid']) ? absint($post['reset_uid']) : 0;
|
||||
$pass1 = (string) ($post['pass1'] ?? '');
|
||||
$pass2 = (string) ($post['pass2'] ?? '');
|
||||
|
||||
$user = self::validate_reset_token($user_id, $token);
|
||||
if (is_wp_error($user)) {
|
||||
return ['success' => false, 'error' => $user->get_error_message(), 'mode' => 'invalid'];
|
||||
}
|
||||
|
||||
$password_check = self::validate_password($pass1, $pass2);
|
||||
if ($password_check !== true) {
|
||||
return ['success' => false, 'error' => $password_check, 'mode' => 'reset'];
|
||||
}
|
||||
|
||||
wp_set_password($pass1, $user_id);
|
||||
self::clear_reset_token($user_id);
|
||||
|
||||
return ['success' => true, 'message' => __('密码已设置成功!', 'aksrt')];
|
||||
}
|
||||
|
||||
private static function create_reset_token(int $user_id): string {
|
||||
$token = wp_generate_password(48, false, false);
|
||||
update_user_meta($user_id, self::RESET_TOKEN_HASH_META, hash('sha256', $token));
|
||||
update_user_meta($user_id, self::RESET_TOKEN_EXPIRES_META, time() + self::RESET_TOKEN_TTL);
|
||||
update_user_meta($user_id, self::RESET_TOKEN_REQUESTED_AT, time());
|
||||
return $token;
|
||||
}
|
||||
|
||||
private static function validate_reset_token(int $user_id, string $token) {
|
||||
if ($user_id <= 0 || $token === '') {
|
||||
return new \WP_Error('invalid_reset_token', __('重置链接无效或已过期', 'aksrt'));
|
||||
}
|
||||
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user || is_wp_error($user)) {
|
||||
return new \WP_Error('invalid_reset_token', __('重置链接无效或已过期', 'aksrt'));
|
||||
}
|
||||
|
||||
$stored_hash = (string) get_user_meta($user_id, self::RESET_TOKEN_HASH_META, true);
|
||||
$expires_at = (int) get_user_meta($user_id, self::RESET_TOKEN_EXPIRES_META, true);
|
||||
$token_hash = hash('sha256', $token);
|
||||
|
||||
if ($stored_hash === '' || $expires_at <= time() || !hash_equals($stored_hash, $token_hash)) {
|
||||
return new \WP_Error('invalid_reset_token', __('重置链接无效或已过期', 'aksrt'));
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private static function clear_reset_token(int $user_id): void {
|
||||
delete_user_meta($user_id, self::RESET_TOKEN_HASH_META);
|
||||
delete_user_meta($user_id, self::RESET_TOKEN_EXPIRES_META);
|
||||
delete_user_meta($user_id, self::RESET_TOKEN_REQUESTED_AT);
|
||||
}
|
||||
|
||||
private static function send_password_reset_email(\WP_User $user, string $token): bool {
|
||||
$reset_url = aksrt_get_reset_password_url($token, $user->ID);
|
||||
$site_name = get_bloginfo('name');
|
||||
$default_subject = '[' . $site_name . '] ' . __('重置密码', 'aksrt');
|
||||
$default_content = sprintf(
|
||||
__("您好 %s,\n\n有人请求重置您在 %s 的密码。\n\n请点击以下链接设置新密码:\n\n%s\n\n如果这不是您本人的操作,请忽略此邮件,您的密码不会被更改。", 'aksrt'),
|
||||
$user->user_login, $site_name, $reset_url
|
||||
);
|
||||
|
||||
return \AKSRT\Settings\Settings::send_event_email(
|
||||
'password_reset', $user->user_email, $default_subject, $default_content,
|
||||
['{username}' => $user->user_login, '{site_name}' => $site_name, '{url}' => $reset_url]
|
||||
);
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
* 手机号认证(注册 / 登录 / 绑定)
|
||||
* 对齐 zibll 的 signup_captcha / signin_captcha / bind_phone_captcha
|
||||
* ================================================================= */
|
||||
|
||||
/**
|
||||
* 手机号注册
|
||||
*
|
||||
* @param array $post POST 数据 (phone, code, password, invite_code)
|
||||
* @return array{success: bool, user_id?: int, error?: string, redirect?: string}
|
||||
*/
|
||||
public static function register_by_phone(array $post): array {
|
||||
$phone = sanitize_text_field((string) ($post['phone'] ?? ''));
|
||||
$code = sanitize_text_field((string) ($post['code'] ?? ''));
|
||||
$pass = (string) ($post['password'] ?? '');
|
||||
|
||||
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
|
||||
return ['success' => false, 'error' => __('手机号格式不正确', 'aksrt')];
|
||||
}
|
||||
if (empty($code)) {
|
||||
return ['success' => false, 'error' => __('请输入验证码', 'aksrt')];
|
||||
}
|
||||
if (strlen($pass) < 6) {
|
||||
return ['success' => false, 'error' => __('密码至少 6 位', 'aksrt')];
|
||||
}
|
||||
|
||||
if (function_exists('aksrt_require_captcha')) {
|
||||
$captcha_result = aksrt_require_captcha('register', $post);
|
||||
if ($captcha_result !== true) {
|
||||
return ['success' => false, 'error' => $captcha_result];
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 SMS 验证码
|
||||
if (class_exists('\AKSRT\Sms\SMS')) {
|
||||
$verify_result = \AKSRT\Sms\SMS::get_instance()->verify_code($phone, $code);
|
||||
if (empty($verify_result['success'])) {
|
||||
return ['success' => false, 'error' => $verify_result['message'] ?? __('验证码不正确', 'aksrt')];
|
||||
}
|
||||
} else {
|
||||
return ['success' => false, 'error' => __('短信服务不可用', 'aksrt')];
|
||||
}
|
||||
|
||||
// 检查手机号是否已注册
|
||||
$existing_user = self::get_user_by_phone($phone);
|
||||
if ($existing_user) {
|
||||
return ['success' => false, 'error' => __('该手机号已注册', 'aksrt')];
|
||||
}
|
||||
|
||||
// 生成用户名(phone_ + 后4位)
|
||||
$username = 'phone_' . substr($phone, -4);
|
||||
$suffix = 1;
|
||||
$base = $username;
|
||||
while (username_exists($username)) {
|
||||
$username = $base . '_' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
$user_id = wp_create_user($username, $pass, $phone . '@sms.aksrt.local');
|
||||
if (is_wp_error($user_id)) {
|
||||
return ['success' => false, 'error' => $user_id->get_error_message()];
|
||||
}
|
||||
|
||||
// 绑定手机号
|
||||
update_user_meta($user_id, 'aksrt_phone', $phone);
|
||||
update_user_meta($user_id, 'aksrt_phone_verified', '1');
|
||||
|
||||
// 邮箱标记为已验证(手机注册等同于已验证)
|
||||
update_user_meta($user_id, 'aksrt_email_verified', '1');
|
||||
|
||||
// 处理邀请码
|
||||
$invite_code = sanitize_key((string) ($post['invite_code'] ?? ''));
|
||||
if ($invite_code !== '' && class_exists('\AKSRT\Credits\Invite')) {
|
||||
\AKSRT\Credits\Invite::get_instance()->handle_registration_invite($user_id, $invite_code);
|
||||
}
|
||||
|
||||
// 自动登录
|
||||
wp_set_current_user($user_id);
|
||||
wp_set_auth_cookie($user_id, true);
|
||||
|
||||
$redirect = function_exists('aksrt_get_user_center_url')
|
||||
? aksrt_get_user_center_url()
|
||||
: home_url('/');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'user_id' => $user_id,
|
||||
'redirect' => $redirect,
|
||||
'message' => __('注册成功', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号登录(密码登录)
|
||||
*
|
||||
* @param array $post (phone, password)
|
||||
* @return array
|
||||
*/
|
||||
public static function login_by_phone_password(array $post): array {
|
||||
$phone = sanitize_text_field((string) ($post['phone'] ?? ''));
|
||||
$pass = (string) ($post['password'] ?? '');
|
||||
|
||||
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
$user = self::get_user_by_phone($phone);
|
||||
if (!$user) {
|
||||
return ['success' => false, 'error' => __('该手机号未注册', 'aksrt')];
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if (!wp_check_password($pass, $user->data->user_pass, $user->ID)) {
|
||||
return ['success' => false, 'error' => __('密码不正确', 'aksrt')];
|
||||
}
|
||||
|
||||
wp_set_current_user($user->ID);
|
||||
wp_set_auth_cookie($user->ID, true);
|
||||
|
||||
$redirect = function_exists('aksrt_get_user_center_url')
|
||||
? aksrt_get_user_center_url()
|
||||
: home_url('/');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'user_id' => $user->ID,
|
||||
'redirect' => $redirect,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号免密登录(SMS 验证码登录)
|
||||
*
|
||||
* @param array $post (phone, code)
|
||||
* @return array
|
||||
*/
|
||||
public static function login_by_phone_code(array $post): array {
|
||||
$phone = sanitize_text_field((string) ($post['phone'] ?? ''));
|
||||
$code = sanitize_text_field((string) ($post['code'] ?? ''));
|
||||
|
||||
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 SMS 验证码
|
||||
if (class_exists('\AKSRT\Sms\SMS')) {
|
||||
$verify_result = \AKSRT\Sms\SMS::get_instance()->verify_code($phone, $code);
|
||||
if (empty($verify_result['success'])) {
|
||||
return ['success' => false, 'error' => $verify_result['message'] ?? __('验证码不正确', 'aksrt')];
|
||||
}
|
||||
} else {
|
||||
return ['success' => false, 'error' => __('短信服务不可用', 'aksrt')];
|
||||
}
|
||||
|
||||
$user = self::get_user_by_phone($phone);
|
||||
if (!$user) {
|
||||
return ['success' => false, 'error' => __('该手机号未注册', 'aksrt')];
|
||||
}
|
||||
|
||||
wp_set_current_user($user->ID);
|
||||
wp_set_auth_cookie($user->ID, true);
|
||||
|
||||
$redirect = function_exists('aksrt_get_user_center_url')
|
||||
? aksrt_get_user_center_url()
|
||||
: home_url('/');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'user_id' => $user->ID,
|
||||
'redirect' => $redirect,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号到当前用户
|
||||
*
|
||||
* @param array $post (phone, code)
|
||||
* @return array
|
||||
*/
|
||||
public static function bind_phone(array $post): array {
|
||||
if (!is_user_logged_in()) {
|
||||
return ['success' => false, 'error' => __('请先登录', 'aksrt')];
|
||||
}
|
||||
|
||||
$phone = sanitize_text_field((string) ($post['phone'] ?? ''));
|
||||
$code = sanitize_text_field((string) ($post['code'] ?? ''));
|
||||
|
||||
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
|
||||
return ['success' => false, 'error' => __('手机号格式不正确', 'aksrt')];
|
||||
}
|
||||
|
||||
if (function_exists('aksrt_require_captcha')) {
|
||||
$captcha_result = aksrt_require_captcha('bind_phone', $post);
|
||||
if ($captcha_result !== true) {
|
||||
return ['success' => false, 'error' => $captcha_result];
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 SMS 验证码
|
||||
if (class_exists('\AKSRT\Sms\SMS')) {
|
||||
$verify_result = \AKSRT\Sms\SMS::get_instance()->verify_code($phone, $code);
|
||||
if (empty($verify_result['success'])) {
|
||||
return ['success' => false, 'error' => $verify_result['message'] ?? __('验证码不正确', 'aksrt')];
|
||||
}
|
||||
} else {
|
||||
return ['success' => false, 'error' => __('短信服务不可用', 'aksrt')];
|
||||
}
|
||||
|
||||
// 检查手机号是否已被其他用户绑定
|
||||
$existing = self::get_user_by_phone($phone);
|
||||
if ($existing && (int) $existing->ID !== get_current_user_id()) {
|
||||
return ['success' => false, 'error' => __('该手机号已被其他账号绑定', 'aksrt')];
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
update_user_meta($user_id, 'aksrt_phone', $phone);
|
||||
update_user_meta($user_id, 'aksrt_phone_verified', '1');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => __('手机号绑定成功', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据手机号查找用户
|
||||
*/
|
||||
private static function get_user_by_phone(string $phone): ?\WP_User {
|
||||
$users = get_users([
|
||||
'meta_key' => 'aksrt_phone',
|
||||
'meta_value' => $phone,
|
||||
'number' => 1,
|
||||
'fields' => 'all',
|
||||
]);
|
||||
return !empty($users) ? $users[0] : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
<?php
|
||||
/**
|
||||
* WeChat Official Account (weixingzh) QR / code login helpers.
|
||||
*
|
||||
* Complements OAuth snsapi_userinfo web auth with:
|
||||
* - static QR + keyword verification code (subscribe mode)
|
||||
* - temporary scene QR + poll state (service mode, best-effort)
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Auth;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class WeixinGzh {
|
||||
public const OPT_ACCESS = 'aksrt_weixingzh_access_token';
|
||||
public const OPT_CODES = 'aksrt_weixingzh_login_codes';
|
||||
public const OPT_SCANS = 'aksrt_weixingzh_scan_states';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('template_redirect', [$this, 'maybe_server_verify'], 1);
|
||||
add_action('template_redirect', [$this, 'maybe_server_message'], 2);
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
register_rest_route('aksrt/v1', '/auth/qrcode-login', [
|
||||
'methods' => ['GET', 'POST'],
|
||||
'callback' => [$this, 'rest_qrcode_login'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/auth/check-qrcode-login', [
|
||||
'methods' => ['GET', 'POST'],
|
||||
'callback' => [$this, 'rest_check_qrcode_login'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/auth/weixingzh-code', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_code_login'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function settings(): array {
|
||||
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_weixingzh_settings')) {
|
||||
return (array) \AKSRT\Settings\Settings::get_weixingzh_settings();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function is_enabled(): bool {
|
||||
$s = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
if (!empty($s['oauth_weixingzh_s'])) {
|
||||
return true;
|
||||
}
|
||||
return !empty($s['weixingzh_appid']) && !empty($s['weixingzh_appsecret']);
|
||||
}
|
||||
|
||||
public function rest_qrcode_login(\WP_REST_Request $request) {
|
||||
if (!self::is_enabled()) {
|
||||
return new \WP_REST_Response(['success' => false, 'message' => __('微信公众号登录未开启', 'aksrt')], 400);
|
||||
}
|
||||
$cfg = self::settings();
|
||||
$appid = (string) ($cfg['appid'] ?? '');
|
||||
$secret = (string) ($cfg['appsecret'] ?? '');
|
||||
$gzh_type = (string) ($cfg['gzh_type'] ?? 'service');
|
||||
|
||||
// Subscribe / uncertified: static QR + code form.
|
||||
if ($gzh_type === 'subscribe' || $gzh_type === 'not' || empty($secret)) {
|
||||
$qr = (string) ($cfg['code_qrcode'] ?? '');
|
||||
$keyword = (string) ($cfg['code_keyword'] ?? __('登录', 'aksrt'));
|
||||
if ($qr === '') {
|
||||
return new \WP_REST_Response(['success' => false, 'message' => __('未配置公众号二维码', 'aksrt')], 400);
|
||||
}
|
||||
$html = '<img class="signin-qrcode-img" src="' . esc_url($qr) . '" alt="" style="width:160px;height:160px;object-fit:contain;" />';
|
||||
$html .= '<form class="aksrt-weixingzh-code-form" data-aksrt-weixingzh-code>';
|
||||
$html .= '<p class="muted-2-color em09">' . esc_html(sprintf(__('关注公众号后发送「%s」获取验证码', 'aksrt'), $keyword)) . '</p>';
|
||||
$html .= '<input type="text" name="code" class="form-control" maxlength="8" placeholder="' . esc_attr__('请输入验证码', 'aksrt') . '" required />';
|
||||
$html .= '<button type="submit" class="but c-blue btn-block mt10">' . esc_html__('登录', 'aksrt') . '</button>';
|
||||
$html .= '</form>';
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'mode' => 'code',
|
||||
'qr_html' => $html,
|
||||
'check_url' => '',
|
||||
'token' => '',
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
// Service account: temporary QR with scene string (state).
|
||||
try {
|
||||
$token = $this->get_access_token($appid, $secret);
|
||||
$state = 'aksrt_' . wp_generate_password(16, false, false);
|
||||
$scene = substr(md5($state . wp_salt('auth')), 0, 24);
|
||||
$create = $this->api_post(
|
||||
'https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=' . rawurlencode($token),
|
||||
[
|
||||
'expire_seconds' => 300,
|
||||
'action_name' => 'QR_STR_SCENE',
|
||||
'action_info' => ['scene' => ['scene_str' => $scene]],
|
||||
]
|
||||
);
|
||||
if (empty($create['ticket'])) {
|
||||
$msg = (string) ($create['errmsg'] ?? __('创建二维码失败', 'aksrt'));
|
||||
return new \WP_REST_Response(['success' => false, 'message' => $msg], 400);
|
||||
}
|
||||
$img = 'https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=' . rawurlencode((string) $create['ticket']);
|
||||
$this->put_scan_state($state, [
|
||||
'status' => 'pending',
|
||||
'scene' => $scene,
|
||||
'created' => time(),
|
||||
'openid' => '',
|
||||
'user_id' => 0,
|
||||
]);
|
||||
$html = '<img class="signin-qrcode-img" src="' . esc_url($img) . '" alt="" style="width:160px;height:160px;" />';
|
||||
$html .= '<p class="text-center mt10 em09 muted-2-color"><i class="fab fa-weixin c-green"></i> ' . esc_html__('微信扫码登录', 'aksrt') . '</p>';
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'mode' => 'scan',
|
||||
'qr_html' => $html,
|
||||
'check_url' => '/auth/check-qrcode-login',
|
||||
'token' => $state,
|
||||
],
|
||||
], 200);
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback to static QR form if API fails.
|
||||
$qr = (string) ($cfg['code_qrcode'] ?? '');
|
||||
if ($qr === '') {
|
||||
return new \WP_REST_Response(['success' => false, 'message' => $e->getMessage()], 400);
|
||||
}
|
||||
$html = '<img class="signin-qrcode-img" src="' . esc_url($qr) . '" alt="" style="width:160px;height:160px;" />';
|
||||
$html .= '<p class="muted-2-color em09">' . esc_html__('请使用公众号二维码 + 验证码登录', 'aksrt') . '</p>';
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => ['mode' => 'code', 'qr_html' => $html, 'check_url' => '', 'token' => ''],
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
|
||||
public function rest_check_qrcode_login(\WP_REST_Request $request) {
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$token = sanitize_text_field((string) ($params['token'] ?? $request->get_param('token') ?? ''));
|
||||
if ($token === '') {
|
||||
return new \WP_REST_Response(['success' => false, 'message' => __('缺少 token', 'aksrt')], 400);
|
||||
}
|
||||
$row = $this->get_scan_state($token);
|
||||
if (!$row) {
|
||||
return new \WP_REST_Response(['success' => true, 'data' => ['logged_in' => false, 'status' => 'expired']], 200);
|
||||
}
|
||||
if (($row['status'] ?? '') === 'ok' && !empty($row['user_id'])) {
|
||||
$user = get_user_by('id', (int) $row['user_id']);
|
||||
if ($user instanceof \WP_User) {
|
||||
wp_set_current_user($user->ID);
|
||||
wp_set_auth_cookie($user->ID, true);
|
||||
do_action('wp_login', $user->user_login, $user);
|
||||
$this->delete_scan_state($token);
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logged_in' => true,
|
||||
'redirect' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'),
|
||||
'user' => [
|
||||
'id' => $user->ID,
|
||||
'display_name' => $user->display_name,
|
||||
],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => ['logged_in' => false, 'status' => (string) ($row['status'] ?? 'pending')],
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function rest_code_login(\WP_REST_Request $request) {
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$code = strtoupper(sanitize_text_field((string) ($params['code'] ?? '')));
|
||||
if ($code === '') {
|
||||
return new \WP_REST_Response(['success' => false, 'message' => __('请输入验证码', 'aksrt')], 400);
|
||||
}
|
||||
$openid = $this->consume_code($code);
|
||||
if ($openid === null) {
|
||||
return new \WP_REST_Response(['success' => false, 'message' => __('验证码无效或已过期', 'aksrt')], 400);
|
||||
}
|
||||
$user = $this->login_or_create_by_openid($openid, []);
|
||||
if (is_wp_error($user)) {
|
||||
return new \WP_REST_Response(['success' => false, 'message' => $user->get_error_message()], 400);
|
||||
}
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'logged_in' => true,
|
||||
'redirect' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'),
|
||||
'user' => [
|
||||
'id' => $user->ID,
|
||||
'display_name' => $user->display_name,
|
||||
],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* WeChat server URL verification: /?aksrt_weixingzh=1&signature&echostr
|
||||
*/
|
||||
public function maybe_server_verify(): void {
|
||||
if (empty($_GET['aksrt_weixingzh']) || empty($_GET['echostr']) || empty($_GET['signature'])) {
|
||||
return;
|
||||
}
|
||||
$cfg = self::settings();
|
||||
if (!empty($cfg['token_nocheck'])) {
|
||||
echo (string) wp_unslash($_GET['echostr']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
exit;
|
||||
}
|
||||
$token = (string) ($cfg['token'] ?? '');
|
||||
$tmp = [(string) $token, (string) ($_GET['timestamp'] ?? ''), (string) ($_GET['nonce'] ?? '')];
|
||||
sort($tmp, SORT_STRING);
|
||||
if (sha1(implode($tmp)) === (string) $_GET['signature']) {
|
||||
echo (string) wp_unslash($_GET['echostr']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
exit;
|
||||
}
|
||||
status_header(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive WeChat server push (XML) on same endpoint.
|
||||
*/
|
||||
public function maybe_server_message(): void {
|
||||
if (empty($_GET['aksrt_weixingzh']) || empty($_GET['signature']) || !empty($_GET['echostr'])) {
|
||||
return;
|
||||
}
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
return;
|
||||
}
|
||||
$raw = file_get_contents('php://input');
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
exit;
|
||||
}
|
||||
$xml = @simplexml_load_string($raw, 'SimpleXMLElement', LIBXML_NOCDATA);
|
||||
if (!$xml) {
|
||||
exit;
|
||||
}
|
||||
$from = (string) ($xml->FromUserName ?? '');
|
||||
$to = (string) ($xml->ToUserName ?? '');
|
||||
$msg_type = (string) ($xml->MsgType ?? '');
|
||||
$event = strtolower((string) ($xml->Event ?? ''));
|
||||
$content = trim((string) ($xml->Content ?? ''));
|
||||
$cfg = self::settings();
|
||||
$keyword = (string) ($cfg['code_keyword'] ?? '登录');
|
||||
|
||||
// Scene scan login (service QR).
|
||||
if ($msg_type === 'event' && in_array($event, ['subscribe', 'scan'], true)) {
|
||||
$scene = (string) ($xml->EventKey ?? '');
|
||||
$scene = ltrim($scene, 'qrscene_');
|
||||
if ($scene !== '') {
|
||||
$this->mark_scan_by_scene($scene, $from);
|
||||
}
|
||||
$reply = (string) ($cfg['subscribe_msg'] ?? $cfg['scan_msg'] ?? '');
|
||||
if ($reply !== '') {
|
||||
$this->echo_text_reply($from, $to, $reply);
|
||||
}
|
||||
echo 'success';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Keyword → verification code.
|
||||
if ($msg_type === 'text' && $content !== '' && mb_strtolower($content) === mb_strtolower($keyword)) {
|
||||
$code = $this->issue_code($from);
|
||||
$tpl = (string) ($cfg['code_reply'] ?? __('您的验证码是:%s,有效期 %d 分钟。', 'aksrt'));
|
||||
// Support %s/%d and %code%/%time%.
|
||||
$msg = $tpl;
|
||||
if (str_contains($msg, '%code%')) {
|
||||
$msg = str_replace('%code%', $code, $msg);
|
||||
}
|
||||
if (str_contains($msg, '%time%')) {
|
||||
$msg = str_replace('%time%', '5', $msg);
|
||||
}
|
||||
if (substr_count($msg, '%') >= 2 && str_contains($msg, '%s')) {
|
||||
$msg = sprintf($msg, $code, 5);
|
||||
} elseif (str_contains($msg, '%s')) {
|
||||
$msg = sprintf($msg, $code);
|
||||
}
|
||||
$this->echo_text_reply($from, $to, $msg);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Optional auto replies.
|
||||
if ($msg_type === 'text' && !empty($cfg['auto_reply_text'])) {
|
||||
$this->echo_text_reply($from, $to, (string) $cfg['auto_reply_text']);
|
||||
exit;
|
||||
}
|
||||
echo 'success';
|
||||
exit;
|
||||
}
|
||||
|
||||
private function echo_text_reply(string $to_user, string $from_user, string $content): void {
|
||||
$time = time();
|
||||
$content = htmlspecialchars($content, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||
echo "<xml><ToUserName><![CDATA[{$to_user}]]></ToUserName><FromUserName><![CDATA[{$from_user}]]></FromUserName><CreateTime>{$time}</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[{$content}]]></Content></xml>";
|
||||
exit;
|
||||
}
|
||||
|
||||
private function issue_code(string $openid): string {
|
||||
$charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$code = '';
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$code .= $charset[random_int(0, strlen($charset) - 1)];
|
||||
}
|
||||
$data = get_option(self::OPT_CODES, []);
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
$now = time();
|
||||
// prune
|
||||
foreach ($data as $k => $row) {
|
||||
if (!is_array($row) || (int) ($row['time'] ?? 0) + 300 < $now) {
|
||||
unset($data[$k]);
|
||||
}
|
||||
}
|
||||
$data[$code] = ['time' => $now, 'openid' => $openid];
|
||||
update_option(self::OPT_CODES, $data, false);
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function consume_code(string $code): ?string {
|
||||
$data = get_option(self::OPT_CODES, []);
|
||||
if (!is_array($data) || empty($data[$code])) {
|
||||
return null;
|
||||
}
|
||||
$row = $data[$code];
|
||||
unset($data[$code]);
|
||||
update_option(self::OPT_CODES, $data, false);
|
||||
if ((int) ($row['time'] ?? 0) + 300 < time()) {
|
||||
return null;
|
||||
}
|
||||
$openid = (string) ($row['openid'] ?? '');
|
||||
return $openid !== '' ? $openid : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $profile
|
||||
* @return \WP_User|\WP_Error
|
||||
*/
|
||||
private function login_or_create_by_openid(string $openid, array $profile) {
|
||||
$oauth = class_exists(OAuth::class) ? OAuth::get_instance() : null;
|
||||
$user_data = [
|
||||
'provider' => 'weixingzh',
|
||||
'provider_id' => $openid,
|
||||
'username' => (string) ($profile['nickname'] ?? ''),
|
||||
'name' => (string) ($profile['nickname'] ?? ''),
|
||||
'email' => '',
|
||||
'avatar_url' => (string) ($profile['headimgurl'] ?? ''),
|
||||
];
|
||||
|
||||
// Find by meta.
|
||||
$users = get_users([
|
||||
'meta_key' => 'aksrt_weixingzh_id',
|
||||
'meta_value' => $openid,
|
||||
'number' => 1,
|
||||
'fields' => 'all',
|
||||
]);
|
||||
if (!empty($users[0]) && $users[0] instanceof \WP_User) {
|
||||
$user = $users[0];
|
||||
wp_set_current_user($user->ID);
|
||||
wp_set_auth_cookie($user->ID, true);
|
||||
do_action('wp_login', $user->user_login, $user);
|
||||
return $user;
|
||||
}
|
||||
|
||||
$login = 'wx' . substr(md5($openid), 0, 10);
|
||||
$uid = wp_insert_user([
|
||||
'user_login' => $login,
|
||||
'user_pass' => wp_generate_password(24, true, true),
|
||||
'display_name' => $user_data['name'] !== '' ? $user_data['name'] : $login,
|
||||
'nickname' => $user_data['name'] !== '' ? $user_data['name'] : $login,
|
||||
'role' => get_option('default_role', 'subscriber'),
|
||||
]);
|
||||
if (is_wp_error($uid)) {
|
||||
return $uid;
|
||||
}
|
||||
update_user_meta((int) $uid, 'aksrt_weixingzh_id', $openid);
|
||||
$bindings = get_user_meta((int) $uid, 'aksrt_oauth_bindings', true);
|
||||
$bindings = is_array($bindings) ? $bindings : [];
|
||||
if (!in_array('weixingzh', $bindings, true)) {
|
||||
$bindings[] = 'weixingzh';
|
||||
update_user_meta((int) $uid, 'aksrt_oauth_bindings', $bindings);
|
||||
}
|
||||
if ($user_data['avatar_url'] !== '') {
|
||||
update_user_meta((int) $uid, 'aksrt_oauth_avatar', esc_url_raw($user_data['avatar_url']));
|
||||
}
|
||||
$user = get_user_by('id', (int) $uid);
|
||||
if (!$user instanceof \WP_User) {
|
||||
return new \WP_Error('aksrt_wx_user', __('创建用户失败', 'aksrt'));
|
||||
}
|
||||
wp_set_current_user($user->ID);
|
||||
wp_set_auth_cookie($user->ID, true);
|
||||
do_action('wp_login', $user->user_login, $user);
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
*/
|
||||
private function put_scan_state(string $state, array $row): void {
|
||||
$all = get_option(self::OPT_SCANS, []);
|
||||
if (!is_array($all)) {
|
||||
$all = [];
|
||||
}
|
||||
$now = time();
|
||||
foreach ($all as $k => $v) {
|
||||
if (!is_array($v) || (int) ($v['created'] ?? 0) + 600 < $now) {
|
||||
unset($all[$k]);
|
||||
}
|
||||
}
|
||||
$all[$state] = $row;
|
||||
update_option(self::OPT_SCANS, $all, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function get_scan_state(string $state): ?array {
|
||||
$all = get_option(self::OPT_SCANS, []);
|
||||
if (!is_array($all) || empty($all[$state]) || !is_array($all[$state])) {
|
||||
return null;
|
||||
}
|
||||
return $all[$state];
|
||||
}
|
||||
|
||||
private function delete_scan_state(string $state): void {
|
||||
$all = get_option(self::OPT_SCANS, []);
|
||||
if (!is_array($all)) {
|
||||
return;
|
||||
}
|
||||
unset($all[$state]);
|
||||
update_option(self::OPT_SCANS, $all, false);
|
||||
}
|
||||
|
||||
private function mark_scan_by_scene(string $scene, string $openid): void {
|
||||
$all = get_option(self::OPT_SCANS, []);
|
||||
if (!is_array($all)) {
|
||||
return;
|
||||
}
|
||||
foreach ($all as $state => $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if ((string) ($row['scene'] ?? '') === $scene) {
|
||||
$user = $this->login_or_create_by_openid($openid, []);
|
||||
if ($user instanceof \WP_User) {
|
||||
// Don't keep session for server push path; store id for poller.
|
||||
wp_logout();
|
||||
$all[$state]['status'] = 'ok';
|
||||
$all[$state]['openid'] = $openid;
|
||||
$all[$state]['user_id'] = $user->ID;
|
||||
update_option(self::OPT_SCANS, $all, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function get_access_token(string $appid, string $secret): string {
|
||||
$cached = get_option(self::OPT_ACCESS, []);
|
||||
if (is_array($cached) && !empty($cached['access_token']) && (int) ($cached['expiration_time'] ?? 0) > time() + 60) {
|
||||
return (string) $cached['access_token'];
|
||||
}
|
||||
$url = add_query_arg([
|
||||
'grant_type' => 'client_credential',
|
||||
'appid' => $appid,
|
||||
'secret' => $secret,
|
||||
], 'https://api.weixin.qq.com/cgi-bin/token');
|
||||
// Prefer stable_token when available.
|
||||
$res = wp_remote_post('https://api.weixin.qq.com/cgi-bin/stable_token', [
|
||||
'timeout' => 10,
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => wp_json_encode([
|
||||
'grant_type' => 'client_credential',
|
||||
'appid' => $appid,
|
||||
'secret' => $secret,
|
||||
]),
|
||||
]);
|
||||
if (is_wp_error($res)) {
|
||||
$res = wp_remote_get($url, ['timeout' => 10]);
|
||||
}
|
||||
if (is_wp_error($res)) {
|
||||
throw new \Exception($res->get_error_message());
|
||||
}
|
||||
$data = json_decode((string) wp_remote_retrieve_body($res), true);
|
||||
if (empty($data['access_token'])) {
|
||||
throw new \Exception((string) ($data['errmsg'] ?? 'access_token failed'));
|
||||
}
|
||||
$data['expiration_time'] = time() + (int) ($data['expires_in'] ?? 7200);
|
||||
update_option(self::OPT_ACCESS, $data, false);
|
||||
return (string) $data['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $body
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function api_post(string $url, array $body): array {
|
||||
$res = wp_remote_post($url, [
|
||||
'timeout' => 10,
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => wp_json_encode($body, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
if (is_wp_error($res)) {
|
||||
return ['errcode' => 1, 'errmsg' => $res->get_error_message()];
|
||||
}
|
||||
$data = json_decode((string) wp_remote_retrieve_body($res), true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user