diff --git a/api/account.php b/api/account.php index 9c2f722..006219d 100644 --- a/api/account.php +++ b/api/account.php @@ -314,7 +314,7 @@ function acctCredentials($player) { /** 第一层密码比对:只认主密码("完整密码"语义,临时密码不算)。 */ function acctPwdCheck($cred, $pwd) { if (!$cred || !isset($cred['password_hash']) || $cred['password_hash'] === '') return false; - $saltBytes = base64_decode((string)$cred['salt'], true); + $saltBytes = saltDecode((string)$cred['salt']); // ★第48批:容错解盐(core.php saltDecode) if ($saltBytes === false || $saltBytes === '') return false; $calc = base64_encode(hash('sha256', $saltBytes . $pwd, true)); return hash_equals((string)$cred['password_hash'], $calc); @@ -693,7 +693,9 @@ function acctChangePassword() { $confirm = (string)getParam('confirm_password', ''); if ($newPwd === '') error('请输入新密码'); if ($confirm !== '' && $confirm !== $newPwd) error('两次输入的新密码不一致'); - if (strlen($newPwd) < 6 || strlen($newPwd) > 20) error('密码长度必须为6-20位'); + // ★第48批#245:与游戏 PasswordUtils.validate 同一把尺子(6-20位+大写+小写+数字) + $ruleErr = pwdRuleError($newPwd); + if ($ruleErr !== null) error($ruleErr); $cred = acctCredentials($player); if (!$cred) error('账号未设置密码'); diff --git a/api/sync.php b/api/sync.php index a5e20ba..481b820 100644 --- a/api/sync.php +++ b/api/sync.php @@ -4683,7 +4683,9 @@ function resetPassword() { if (!$resetToken) error('缺少reset_token参数'); if (!$newPassword) error('缺少new_password参数'); - if (strlen($newPassword) < 6) error('密码长度至少6位'); + // ★第48批#245:与游戏 PasswordUtils.validate 同一把尺子(6-20位+大写+小写+数字) + $ruleErr = pwdRuleError($newPassword); + if ($ruleErr !== null) error($ruleErr); $db = getDB(); @@ -4839,9 +4841,10 @@ function changePassword() { error('缺少必要参数'); } - // 验证新密码格式 - if (strlen($newPwd) < 6 || strlen($newPwd) > 20) { - error('密码长度必须为6-20位'); + // ★第48批#245:与游戏 PasswordUtils.validate 同一把尺子(6-20位+大写+小写+数字) + $ruleErr = pwdRuleError($newPwd); + if ($ruleErr !== null) { + error($ruleErr); } $db = getDB(); @@ -4854,8 +4857,8 @@ function changePassword() { error('玩家不存在'); } - // 验证旧密码 - $saltBytes = base64_decode($row['salt'], true); + // 验证旧密码(★第48批#244:容错解盐,脏盐玩家也能在网页改密) + $saltBytes = saltDecode($row['salt']); if ($saltBytes === false || $saltBytes === '') { error('密码盐值无效'); } diff --git a/api/wallpaper.php b/api/wallpaper.php index 4ebc314..892d76a 100644 --- a/api/wallpaper.php +++ b/api/wallpaper.php @@ -80,7 +80,8 @@ $act = isset($_GET['action']) ? $_GET['action'] : ''; /* ===== 每日壁纸元数据(第47批:多源 fallback 链) ===== */ if ($act === 'bing') { - $cached = wpjCacheGet('bing', WPJ_BING_TTL); + // ★第48批#247:key 版本化 bing -> bing_v2(旧缓存存着无 api/ 前缀的坏 URL,须失效) + $cached = wpjCacheGet('bing_v2', WPJ_BING_TTL); if ($cached !== null) { wpjOut(true, 'ok', $cached); } $data = null; @@ -94,7 +95,8 @@ if ($act === 'bing') { $direct = $base . $img['urlbase'] . '_1920x1080.jpg'; $data = array( // 前端优先用代理地址(防防盗链),direct 作兜底直连 - 'url' => 'wallpaper.php?action=image&u=' . rawurlencode($direct), + // ★第48批#247:补 api/ 前缀(原相对路径经前端路由转发后 404,图片取不到) + 'url' => 'api/wallpaper.php?action=image&u=' . rawurlencode($direct), 'direct' => $direct, 'source' => 'bing', 'title' => isset($img['title']) ? $img['title'] : '', @@ -111,7 +113,8 @@ if ($act === 'bing') { if (is_array($j) && !empty($j['hdurl']) && preg_match('#^https?://#i', $j['hdurl'])) { $direct = $j['hdurl']; $data = array( - 'url' => 'wallpaper.php?action=image&u=' . rawurlencode($direct), + // ★第48批#247:补 api/ 前缀(同 bing) + 'url' => 'api/wallpaper.php?action=image&u=' . rawurlencode($direct), 'direct' => $direct, 'source' => 'nasa', 'title' => isset($j['title']) ? $j['title'] : 'NASA 每日天文图', @@ -134,7 +137,7 @@ if ($act === 'bing') { ); } - wpjCacheSet('bing', $data); + wpjCacheSet('bing_v2', $data); wpjOut(true, 'ok', $data); } diff --git a/core.php b/core.php index d20706c..edbae35 100644 --- a/core.php +++ b/core.php @@ -3040,6 +3040,39 @@ function storeTempPassword($playerName, $tempHash, $expire) { } } +/** + * 游戏同款密码强度规则(第48批#245,与 Java PasswordUtils.validate 对齐): + * 长度 6-20 位 + 至少 1 个大写字母 + 至少 1 个小写字母 + 至少 1 个数字。 + * 网页端改密/重置必须走同一把尺子,避免出现「网页能设、游戏登不上」的密码。 + * + * @return string|null 通过返回 null;不通过返回错误文案(可直接 error()) + */ +function pwdRuleError($pwd) { + $pwd = (string)$pwd; + $len = strlen($pwd); + if ($len < 6 || $len > 20) return '密码长度必须为6-20位'; + if (!preg_match('/[A-Z]/', $pwd)) return '密码需包含大写字母'; + if (!preg_match('/[a-z]/', $pwd)) return '密码需包含小写字母'; + if (!preg_match('/[0-9]/', $pwd)) return '密码需包含数字'; + return null; +} + +/** + * 容错解盐(第48批):镜像 Java PasswordUtils 的清洗逻辑。 + * 游戏侧偶发脏盐(含 JSON 转义反斜杠等非 base64 字符), + * Java 端 hash 前会 replaceAll("[^A-Za-z0-9+/=]") 清洗后再用,游戏端正常; + * PHP strict base64_decode 却直接失败 → 该玩家网页端永远密码错误。 + * 故 PHP 侧同款清洗后再 strict 解码——清洗等价于 Java,哈希结果不变。 + * @return string|false 解出的原始盐字节;清洗后仍无法解码返回 false + */ +function saltDecode($salt) { + $clean = preg_replace('/[^A-Za-z0-9+\/=]/', '', (string)$salt); + if ($clean === '' || $clean === null) return false; + $bytes = base64_decode($clean, true); + if ($bytes === false || $bytes === '') return false; + return $bytes; +} + /** * 验证Web登录密码(优先校验临时密码,再校验主密码) * 返回: 'temp' 表示临时密码, 'main' 表示主密码, false 表示失败 @@ -3067,7 +3100,7 @@ function verifyWebLoginPassword($playerName, $password) { $tempExpire = (int)$row['temp_pw_expire']; if (time() < $tempExpire && $row['temp_password_hash'] !== '') { $salt = $row['salt']; - $saltBytes = base64_decode($salt, true); + $saltBytes = saltDecode($salt); // ★第48批:容错解盐(镜像Java清洗) if ($saltBytes !== false && $saltBytes !== '') { $rawHash = hash('sha256', $saltBytes . $password, true); $computedHash = base64_encode($rawHash); @@ -3081,7 +3114,7 @@ function verifyWebLoginPassword($playerName, $password) { // 2. 再检查主密码 $storedHash = $row['password_hash']; $salt = $row['salt']; - $saltBytes = base64_decode($salt, true); + $saltBytes = saltDecode($salt); // ★第48批:容错解盐(镜像Java清洗) if ($saltBytes === false || $saltBytes === '') return false; $rawHash = hash('sha256', $saltBytes . $password, true); diff --git a/player.php b/player.php index 4250ef4..95538b3 100644 --- a/player.php +++ b/player.php @@ -3011,6 +3011,7 @@ if ($currentVersion !== $BUILD_VERSION) { let _acctState = null; let _lastRecoveryCodes = []; + let _lastChangedPwd = ''; // ★第48批#246:改密成功面板明文密码(复制按钮用) function startAccountVerify(action, label) { if (IS_PREVIEW || !TOKEN || !currentPlayer) { showToast('请先登录游戏后再操作', 'error'); return; } @@ -3203,36 +3204,78 @@ if ($currentVersion !== $BUILD_VERSION) { function acctPromptNewPassword(ticket) { acctModalOpen('🛡️ 修改登录密码'); acctModalSet(` -
两层验证已通过,请设置新密码
-
+
两层验证已通过,请设置新密码(与游戏内同一套规则)
+
+
+
○ 长度 6-20 位
+
○ 含大写字母
+
○ 含小写字母
+
○ 含数字
+
`, '确认修改', () => acctDoChangePassword(ticket)); + acctPwdChecklist(); setTimeout(() => { const p = document.getElementById('acctNewPwd'); if (p) p.focus(); }, 120); } + // ---- ★第48批#245:密码强度实时校验清单(与游戏 PasswordUtils.validate 同一把尺子) ---- + function acctPwdChecklist() { + const v = (document.getElementById('acctNewPwd') || {}).value || ''; + const checks = [ + ['pwrLen', v.length >= 6 && v.length <= 20], + ['pwrUp', /[A-Z]/.test(v)], + ['pwrLow', /[a-z]/.test(v)], + ['pwrNum', /[0-9]/.test(v)] + ]; + let allOk = v.length > 0; + for (const [id, ok] of checks) { + const el = document.getElementById(id); + if (el) { + el.textContent = (ok ? '✓ ' : '✗ ') + el.textContent.slice(2); + el.style.color = ok ? 'var(--green, #4caf50)' : 'var(--dim)'; + } + if (!ok) allOk = false; + } + const btn = document.getElementById('modalConfirm'); + if (btn && !btn.disabled) btn.disabled = !allOk; // 全绿才允许提交 + return allOk; + } + async function acctDoChangePassword(ticket) { const p1 = (document.getElementById('acctNewPwd') || {}).value || ''; const p2 = (document.getElementById('acctNewPwd2') || {}).value || ''; if (!p1 || !p2) { acctErr('请填写两遍新密码'); return; } if (p1 !== p2) { acctErr('两次输入的密码不一致'); return; } - if (p1.length < 6 || p1.length > 20) { acctErr('密码长度必须为6-20位'); return; } + if (!acctPwdChecklist()) { acctErr('密码需满足全部4项要求(6-20位 + 大写 + 小写 + 数字)'); return; } const btn = document.getElementById('modalConfirm'); btn.disabled = true; acctErr('提交中...'); try { const res = await apiPost('account.php', {action: 'change_password', ticket: ticket, new_password: p1, confirm_password: p2}); btn.disabled = false; - if (!res.success) { acctErr(res.message || '修改失败'); return; } - closeModal(); - showToast('✅ 密码修改成功', 'success'); + if (!res.success) { acctErr(res.message || '修改失败'); acctPwdChecklist(); return; } try { notifyJavaSync(); } catch (e) {} + acctShowPwdSuccess(p1); acctRefreshStatus(); } catch (e) { btn.disabled = false; acctErr('连接失败: ' + e.message); + acctPwdChecklist(); } } + // ---- ★第48批#246:改密成功面板(不关弹窗,明文密码留屏供截屏保存) ---- + function acctShowPwdSuccess(newPwd) { + _lastChangedPwd = newPwd; + acctModalOpen('✅ 密码修改成功'); + acctModalSet(` +
请立即截屏或复制保存以下新密码
+
${escapeHtml(newPwd)}
+ +
已请求同步到游戏端(通常 1-2 分钟内生效)。
若游戏内仍提示密码错误,请退出重进后再试一次。
`, + '我已保存,关闭', () => { closeModal(); acctRefreshStatus(); }); + } + // ---- 绑定二次验证器:生成密钥 → 扫码 → 动态码确认 → 一次性返回恢复代码 ---- async function acctFlowTotp(ticket) { acctModalOpen('🛡️ 绑定二次验证器');