diff --git a/assets/js/auth.js b/assets/js/auth.js index d7c5fe07..18e7b8c3 100644 --- a/assets/js/auth.js +++ b/assets/js/auth.js @@ -30,6 +30,7 @@ binding_conflict: '该第三方账号已绑定其他账号', account_conflict: '该第三方账号无法绑定到当前账号', already_bound: '该第三方账号已绑定其他账号', + account_banned: '您的账号已被封禁,禁封期间无法登录', timeout: '第三方授权已超时,请重试', }; var oauthProviderLabels = { diff --git a/assets/js/routes/user-center.js b/assets/js/routes/user-center.js index 9827a1b2..32c7ce8b 100644 --- a/assets/js/routes/user-center.js +++ b/assets/js/routes/user-center.js @@ -1044,6 +1044,7 @@ bind_verification_required: '请先完成人机验证后再绑定', account_conflict: '该第三方账号无法绑定到当前账号', already_bound: '该第三方账号已绑定其他账号', + account_banned: '您的账号已被封禁,禁封期间无法登录', timeout: '第三方授权已超时,请重试' }; var providerLabel = providerLabels[provider] || ''; diff --git a/src/Admin/UserControllerAuthPhoneOperations.php b/src/Admin/UserControllerAuthPhoneOperations.php index 4ab7aa84..0c486034 100644 --- a/src/Admin/UserControllerAuthPhoneOperations.php +++ b/src/Admin/UserControllerAuthPhoneOperations.php @@ -260,10 +260,14 @@ trait UserControllerAuthPhoneOperations { if (!$this->auth_channel_allowed($configured, $channel)) { return $this->error(__('当前验证方式不可用', 'aksrt'), 403); } - $user = $this->auth_find_user($target, $channel); - if (!$user || !$this->auth_consume_code('login', $target, sanitize_text_field((string) $payload['code']), $channel)) { - return $this->error(__('账号或验证码错误', 'aksrt'), 401); - } + $user = $this->auth_find_user($target, $channel); + if (!$user || !$this->auth_consume_code('login', $target, sanitize_text_field((string) $payload['code']), $channel)) { + return $this->error(__('账号或验证码错误', 'aksrt'), 401); + } + // 禁封用户禁止登录(与密码登录 enforce_login_ban 同语义:封号=无法登录)。 + if (class_exists('\AKSRT\User\Restriction') && \AKSRT\User\Restriction::get_instance()->is_banned((int) $user->ID)) { + return $this->error(\AKSRT\User\Restriction::get_instance()->login_ban_message((int) $user->ID), 403); + } if ($oauth_data) { $oauth_data = \AKSRT\Auth\Service::claim_oauth_bind_context($obk); if (!$oauth_data) { @@ -293,7 +297,11 @@ trait UserControllerAuthPhoneOperations { $resolved_login = \AKSRT\Auth\Service::resolve_email_to_username($target); $user = wp_authenticate($resolved_login, (string) ($payload['password'] ?? $payload['pwd'] ?? '')); if (is_wp_error($user) || !$user instanceof \WP_User) { - return $this->error(__('账号或密码错误', 'aksrt'), 401); + // 透传具体失败原因(禁封等),不得泛化为“账号或密码错误”。 + $bind_error = $user instanceof \WP_Error + ? \AKSRT\Auth\Service::friendly_login_error($user->get_error_message(), (string) $user->get_error_code()) + : ''; + return $this->error($bind_error !== '' ? $bind_error : __('账号或密码错误', 'aksrt'), 401); } $oauth_data = \AKSRT\Auth\Service::claim_oauth_bind_context($obk); if (!$oauth_data) { @@ -309,7 +317,9 @@ trait UserControllerAuthPhoneOperations { } $result = \AKSRT\Auth\Service::login_user($payload); if (empty($result['success'])) { - return $this->error(__('账号或密码错误', 'aksrt'), 401); + // 透传具体失败原因:禁封等场景不得泛化为“账号或密码错误”。 + $login_error = trim((string) ($result['error'] ?? '')); + return $this->error($login_error !== '' ? $login_error : __('账号或密码错误', 'aksrt'), 401); } return $this->success(['message' => $oauth_data ? __('账号绑定成功', 'aksrt') : __('登录成功', 'aksrt'), 'redirect' => $redirect]); } diff --git a/src/Auth/OAuth.php b/src/Auth/OAuth.php index a0bcde1f..84f9fda1 100644 --- a/src/Auth/OAuth.php +++ b/src/Auth/OAuth.php @@ -467,7 +467,10 @@ final class OAuth { if (!$user instanceof \WP_User) { $this->redirect_error('account_match_failed', $provider, 'login'); } - OAuthAccountService::complete_login($user, true); + $login_error = OAuthAccountService::complete_login($user, true); + if (is_wp_error($login_error)) { + $this->redirect_error('account_banned', $provider, 'login'); + } $this->redirect_success('login', $provider, OAuthUrlPolicy::validated_redirect($redirect, home_url('/'))); } diff --git a/src/Auth/OAuthAccountService.php b/src/Auth/OAuthAccountService.php index 87f45473..44adb8dc 100644 --- a/src/Auth/OAuthAccountService.php +++ b/src/Auth/OAuthAccountService.php @@ -97,10 +97,27 @@ final class OAuthAccountService { return true; } - public static function complete_login(\WP_User $user, bool $remember = true): void { + /** + * 完成登录(设置会话与 Cookie)。 + * + * 禁封用户禁止登录:与密码登录的 Restriction::enforce_login_ban 同语义 + * (封号=无法登录),覆盖短信验证码、公众号扫码、第三方 OAuth 等 + * 非密码登录路径。返回 WP_Error 时调用方须中止登录并透出原因。 + */ + public static function complete_login(\WP_User $user, bool $remember = true): ?\WP_Error { + if ( + class_exists('\AKSRT\User\Restriction') + && \AKSRT\User\Restriction::get_instance()->is_banned((int) $user->ID) + ) { + return new \WP_Error( + 'user_banned', + \AKSRT\User\Restriction::get_instance()->login_ban_message((int) $user->ID) + ); + } wp_set_current_user($user->ID); wp_set_auth_cookie($user->ID, $remember); do_action('wp_login', $user->user_login, $user); + return null; } public static function bindings(int $user_id): array { diff --git a/src/Auth/OAuthProxyClient.php b/src/Auth/OAuthProxyClient.php index 959a3f30..a94fa2fb 100644 --- a/src/Auth/OAuthProxyClient.php +++ b/src/Auth/OAuthProxyClient.php @@ -209,7 +209,11 @@ final class OAuthProxyClient { $this->redirect_error('account_match_failed', $provider, $action); } - OAuthAccountService::complete_login($user, true); + $login_error = OAuthAccountService::complete_login($user, true); + if (is_wp_error($login_error)) { + $this->redirect_error('account_banned', $provider, $action); + } + $this->redirect_success( 'login', $provider, diff --git a/src/Auth/Service.php b/src/Auth/Service.php index a046ab46..9bd1a795 100644 --- a/src/Auth/Service.php +++ b/src/Auth/Service.php @@ -67,7 +67,13 @@ final class Service { return sanitize_user($login, true); } - public static function friendly_login_error(string $raw_message): string { + public static function friendly_login_error(string $raw_message, string $error_code = ''): string { + // 禁封提示已是完整语义文案(无法登录 + 期限 + 原因),直接透出, + // 不得泛化为“用户名或密码错误”。 + if ('user_banned' === $error_code) { + return wp_strip_all_tags($raw_message); + } + $msg_lower = wp_strip_all_tags(strtolower($raw_message)); if (false !== strpos($msg_lower, 'incorrect password') || false !== strpos($msg_lower, '密码不正确')) { @@ -135,7 +141,10 @@ final class Service { } if (is_wp_error($user)) { - return ['success' => false, 'error' => self::friendly_login_error($user->get_error_message())]; + return [ + 'success' => false, + 'error' => self::friendly_login_error($user->get_error_message(), (string) $user->get_error_code()), + ]; } // REST API 上下文中 wp_signon 可能无法正确设置 Cookie diff --git a/src/Auth/WeixinGzh.php b/src/Auth/WeixinGzh.php index fb287f13..40b2dc19 100644 --- a/src/Auth/WeixinGzh.php +++ b/src/Auth/WeixinGzh.php @@ -442,7 +442,10 @@ final class WeixinGzh { if (!$user instanceof \WP_User) { return $this->error_response(is_wp_error($user) ? $user->get_error_message() : __('微信账号匹配失败', 'aksrt'), 400); } - OAuthAccountService::complete_login($user, true); + $login_error = OAuthAccountService::complete_login($user, true); + if (is_wp_error($login_error)) { + return $this->error_response($login_error->get_error_message(), 403); + } $redirect = OAuthUrlPolicy::validated_redirect((string) ($payload['redirect_to'] ?? ''), home_url('/')); return new \WP_REST_Response([ 'success' => true, diff --git a/src/Settings/UserRuntime.php b/src/Settings/UserRuntime.php index 690c56fe..dd0a3ed9 100644 --- a/src/Settings/UserRuntime.php +++ b/src/Settings/UserRuntime.php @@ -177,7 +177,10 @@ class UserRuntime { 'enabled' => (bool) ($settings['user_report_s'] ?? $settings['user_report_enabled'] ?? true), 'public_entry' => !isset($settings['user_report_public_entry']) || !empty($settings['user_report_public_entry']), 'public_notice' => !isset($settings['user_report_public_notice']) || !empty($settings['user_report_public_notice']), - 'ban_notice_text' => $ban_notice_text !== '' ? $ban_notice_text : __('当前账号处于限制状态,部分互动能力已关闭。', 'aksrt'), + // 原样返回配置值:缺省时由 Home::get_profile_notice() 按治理展示态 + // 取分级默认文案(封号=全面限制/小黑屋=部分限制/禁言=评论域), + // 不再用「部分互动能力已关闭」一刀切描述封号账号。 + 'ban_notice_text' => $ban_notice_text, ], ]; } diff --git a/src/User/Home.php b/src/User/Home.php index 967c6484..c2839b53 100644 --- a/src/User/Home.php +++ b/src/User/Home.php @@ -368,10 +368,18 @@ class Home { return []; } + // 提示尾句:后台配置优先;缺省按治理展示态取分级文案 + // (封号=全面限制,小黑屋=部分限制,禁言=评论域限制)。 + $text = trim((string) ($settings['report']['ban_notice_text'] ?? '')); + if ($text === '') { + $state_notices = \AKSRT\User\Restriction::state_notices(); + $text = (string) ($state_notices[$state] ?? ''); + } + return [ 'state' => $state, 'label' => \AKSRT\User\Restriction::state_labels()[$state] ?? '', - 'text' => (string) $settings['report']['ban_notice_text'], + 'text' => $text, ]; } diff --git a/src/User/Restriction.php b/src/User/Restriction.php index 2169b4a0..19e2cb55 100644 --- a/src/User/Restriction.php +++ b/src/User/Restriction.php @@ -227,6 +227,48 @@ class Restriction { ]; } + /** + * 治理展示态公开提示尾句(资料卡提示框,接在徽章文案之后): + * 封号 = 全面限制(无法登录、功能停用); + * 小黑屋 = 部分限制(发布、评论等操作受限); + * 禁言 = 仅评论域受限。 + * 后台 user_report_ban_notice_text 配置后覆盖全部状态。 + * + * @return array 展示态 => 提示文案 + */ + public static function state_notices(): array { + return [ + self::STATE_BANNED => __('禁封期间无法登录,账号功能全面停用。', 'aksrt'), + self::STATE_RESTRICTED => __('发布、评论等操作已受限制。', 'aksrt'), + self::STATE_MUTED => __('暂时无法发表评论。', 'aksrt'), + ]; + } + + /** + * 登录禁封提示文案(登录拦截 / 免密登录 / Toast 共用)。 + * 对齐参考主题登录拦截页语义:封号 = 全面限制(无法登录),附期限与原因。 + */ + public function login_ban_message(int $user_id): string { + $msg = __('您的账号已被封禁,禁封期间无法登录。', 'aksrt'); + $ban = $this->get_active_ban($user_id); + if (is_array($ban)) { + $msg .= !empty($ban['expires_at']) + ? ' ' . sprintf(__('解封时间:%s。', 'aksrt'), (string) $ban['expires_at']) + : ' ' . __('本次封禁为永久封禁。', 'aksrt'); + $reason = (string) ($ban['reason'] ?? ''); + $marker = "\n" . __('禁封说明:', 'aksrt'); + $pos = strpos($reason, $marker); + if (false !== $pos) { + $reason = substr($reason, 0, $pos); + } + $reason = trim($reason); + if ($reason !== '') { + $msg .= ' ' . sprintf(__('原因:%s', 'aksrt'), $reason); + } + } + return $msg; + } + /** * Check if a user is in 小黑屋 (active global suspend). * @@ -270,12 +312,7 @@ class Restriction { return $user; } if ($this->is_banned($user->ID)) { - $ban = $this->get_active_ban($user->ID); - $message = '您的账号已被封禁。'; - if (!empty($ban['reason'])) { - $message .= '原因:' . $ban['reason']; - } - return new \WP_Error('user_banned', $message); + return new \WP_Error('user_banned', $this->login_ban_message($user->ID)); } return $user; } diff --git a/tests/widget-system/report-ban-contract.test.cjs b/tests/widget-system/report-ban-contract.test.cjs index dc7608a1..3315ef7b 100644 --- a/tests/widget-system/report-ban-contract.test.cjs +++ b/tests/widget-system/report-ban-contract.test.cjs @@ -29,6 +29,15 @@ const singleComponents = read('inc/helpers/aksrt-single-components.php') const banAppeal = read('src/User/BanAppeal.php') const mainCss = read('assets/css/main.css') const mainMinCss = read('assets/css/main.min.css') +const userRuntime = read('src/Settings/UserRuntime.php') +const phoneOps = read('src/Admin/UserControllerAuthPhoneOperations.php') +const authService = read('src/Auth/Service.php') +const oauthAccountService = read('src/Auth/OAuthAccountService.php') +const weixinGzh = read('src/Auth/WeixinGzh.php') +const oauthFlow = read('src/Auth/OAuth.php') +const oauthProxy = read('src/Auth/OAuthProxyClient.php') +const authJs = read('assets/js/auth.js') +const userCenterJs = read('assets/js/routes/user-center.js') test('report REST surface is canonical: submit + users/reports only', () => { assert.match(userController, /'\/report\/submit'/) @@ -147,7 +156,10 @@ test('profile governance notice reads from the restriction table', () => { // 提示框内容契约:state|label|text,label 对齐参考主题公开主页禁封徽章文案。 assert.match(home, /'state'\s*=> \$state/) assert.match(home, /'label'\s*=>/) - assert.match(home, /'text'\s*=> \(string\) \$settings\['report'\]\['ban_notice_text'\]/) + // 提示尾句分级:封号=全面限制(无法登录),不得沿用“部分互动能力已关闭”。 + assert.match(home, /\$text = trim\(\(string\) \(\$settings\['report'\]\['ban_notice_text'\] \?\? ''\)\)/) + assert.match(home, /state_notices\(\)/) + assert.ok(!userRuntime.includes('当前账号处于限制状态,部分互动能力已关闭'), 'generic partial-restriction copy must not be the banned-state default') // 模板必须消费真实契约键:tone/message 双键曾导致资料卡下方提示框渲染为空。 assert.match(authorHero, /\$notice\['state'\]/) assert.match(authorHero, /\$notice\['text'\]/) @@ -163,9 +175,18 @@ test('governance display state is a single source shared by every prompt surface } assert.match(restriction, /public function get_display_state\(int \$user_id\): string/) assert.match(restriction, /public static function state_labels\(\): array/) + assert.match(restriction, /public static function state_notices\(\): array/) for (const label of ["__('违规已封号', 'aksrt')", "__('小黑屋禁封中', 'aksrt')", "__('禁言中', 'aksrt')"]) { assert.ok(restriction.includes(label), `missing state label: ${label}`) } + // 提示尾句分级:封号=全面限制,小黑屋=部分限制,禁言=评论域限制。 + for (const notice of [ + "__('禁封期间无法登录,账号功能全面停用。', 'aksrt')", + "__('发布、评论等操作已受限制。', 'aksrt')", + "__('暂时无法发表评论。', 'aksrt')", + ]) { + assert.ok(restriction.includes(notice), `missing state notice: ${notice}`) + } assert.match(restriction, /is_banned\(\$user_id\)[\s\S]*?is_suspended\(\$user_id\)[\s\S]*?is_restricted\(\$user_id, self::TYPE_MUTE\)/) // 请求级缓存:评论列表逐条渲染昵称徽章不得反复查库。 assert.match(userBanHelper, /static \$cache = \[\]/) @@ -218,6 +239,37 @@ test('report entry hides targets already banned or suspended', () => { assert.match(authorHero, /aksrt_is_user_ban_restricted\(\$user_id\)/) }) +test('banned users get the ban reason on every login path, not a credentials error', () => { + // 登录禁封文案单一来源(全面限制语义 + 期限 + 原因)。 + assert.match(restriction, /public function login_ban_message\(int \$user_id\): string/) + assert.match(restriction, /__\('您的账号已被封禁,禁封期间无法登录。', 'aksrt'\)/) + assert.match(restriction, /new \\WP_Error\('user_banned', \$this->login_ban_message/) + // Toast 透传:REST 层不得把失败原因硬编码为“账号或密码错误”。 + assert.match(phoneOps, /\$result\['error'\]/) + assert.ok( + phoneOps.includes("$login_error !== '' ? $login_error : __('账号或密码错误', 'aksrt')"), + 'auth_login must pass through the real failure reason' + ) + assert.match(authService, /friendly_login_error\(\$user->get_error_message\(\), \(string\) \$user->get_error_code\(\)\)/) + assert.match(authService, /'user_banned' === \$error_code/) + // 短信验证码(免密)登录禁封闸口:封号用户不得经此路径登录。 + assert.match(phoneOps, /is_banned\(\(int\) \$user->ID\)/) + assert.match(phoneOps, /login_ban_message\(\(int\) \$user->ID\)/) + // 非密码登录统一收口:complete_login 返回 WP_Error,调用方必须中止。 + assert.match(oauthAccountService, /public static function complete_login\(\\WP_User \$user, bool \$remember = true\): \?\\WP_Error/) + assert.match(oauthAccountService, /is_banned\(\(int\) \$user->ID\)/) + assert.match(weixinGzh, /\$login_error = OAuthAccountService::complete_login/) + assert.match(weixinGzh, /error_response\(\$login_error->get_error_message\(\), 403\)/) + assert.match(oauthFlow, /\$login_error = OAuthAccountService::complete_login/) + assert.match(oauthFlow, /redirect_error\('account_banned'/) + assert.match(oauthProxy, /\$login_error = OAuthAccountService::complete_login/) + assert.match(oauthProxy, /redirect_error\('account_banned'/) + // 前端 OAuth 错误映射同步补充 account_banned。 + for (const js of [authJs, userCenterJs]) { + assert.match(js, /account_banned: '您的账号已被封禁,禁封期间无法登录'/) + } +}) + test('admin report notification links to the real page', () => { assert.match(mailNotifications, /users\.php\?page=aksrt_user_ban'/) assert.ok(!mailNotifications.includes('page=aksrt_reports&'), 'dead admin page link')