fix(ban): state-aware ban copy and real login failure reasons
- fix(profile): 封号用户的公开主页提示不再沿用「当前账号处于限制状态, 部分互动能力已关闭」——封号=全面限制(无法登录),该描述与语境不符。 提示尾句按治理展示态分级:封号→禁封期间无法登录、账号功能全面停用; 小黑屋→发布、评论等操作已受限制;禁言→暂时无法发表评论。 UserRuntime 不再烘焙通用默认文案,缺省时由 get_profile_notice 按态取 - fix(auth): 登录失败透传真实原因——REST auth_login 此前将 Service::login_user 返回的具体错误硬编码为「账号或密码错误」, 禁封用户登录时 Toast 语境错误;现透传 error 字段, friendly_login_error 识别 user_banned 错误码后直接返回禁封文案 (无法登录 + 解封时间/永久 + 原因) - fix(auth): 禁封闸口覆盖全部登录路径——此前仅密码登录经 enforce_login_ban 拦截,短信验证码、公众号扫码、第三方 OAuth 均 直连 complete_login 可绕过封号限制;现将闸口收敛至 complete_login (返回 WP_Error),各调用方中止登录并透出原因;OAuth 重定向新增 account_banned 错误码,auth.js 与 user-center.js 同步文案映射 - test: 新增登录禁封面契约测试(文案单一来源、Toast 透传、免密闸口、 统一收口、前端映射),全量 362 项通过
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
binding_conflict: '该第三方账号已绑定其他账号',
|
||||
account_conflict: '该第三方账号无法绑定到当前账号',
|
||||
already_bound: '该第三方账号已绑定其他账号',
|
||||
account_banned: '您的账号已被封禁,禁封期间无法登录',
|
||||
timeout: '第三方授权已超时,请重试',
|
||||
};
|
||||
var oauthProviderLabels = {
|
||||
|
||||
@@ -1044,6 +1044,7 @@
|
||||
bind_verification_required: '请先完成人机验证后再绑定',
|
||||
account_conflict: '该第三方账号无法绑定到当前账号',
|
||||
already_bound: '该第三方账号已绑定其他账号',
|
||||
account_banned: '您的账号已被封禁,禁封期间无法登录',
|
||||
timeout: '第三方授权已超时,请重试'
|
||||
};
|
||||
var providerLabel = providerLabels[provider] || '';
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
+4
-1
@@ -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('/')));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
+9
-1
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -227,6 +227,48 @@ class Restriction {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 治理展示态公开提示尾句(资料卡提示框,接在徽章文案之后):
|
||||
* 封号 = 全面限制(无法登录、功能停用);
|
||||
* 小黑屋 = 部分限制(发布、评论等操作受限);
|
||||
* 禁言 = 仅评论域受限。
|
||||
* 后台 user_report_ban_notice_text 配置后覆盖全部状态。
|
||||
*
|
||||
* @return array<string,string> 展示态 => 提示文案
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user