新增管理登录页面
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台隐藏登录控制器
|
||||
*
|
||||
* 仅提供站长独立登录入口,登录成功后直接进入后台控制台,
|
||||
* 不经过聊天室首页与“登录即注册”流程。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AdminLoginRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 类功能:处理站长隐藏登录页展示与登录提交。
|
||||
*/
|
||||
class AdminAuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* 隐藏登录入口后缀。
|
||||
*/
|
||||
private const LOGIN_SUFFIX = 'lkddi';
|
||||
|
||||
/**
|
||||
* 站长账号固定主键。
|
||||
*/
|
||||
private const SITE_OWNER_ID = 1;
|
||||
|
||||
/**
|
||||
* 显示站长隐藏登录页面。
|
||||
*/
|
||||
public function create(Request $request): View|RedirectResponse
|
||||
{
|
||||
// 已通过隐藏入口登录的站长再次访问时,直接回后台首页
|
||||
if (Auth::id() === self::SITE_OWNER_ID && $request->session()->get('admin_login_via_hidden')) {
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
|
||||
return view('admin.auth.login', [
|
||||
'loginSuffix' => self::LOGIN_SUFFIX,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理站长隐藏登录请求。
|
||||
*/
|
||||
public function store(AdminLoginRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$siteOwner = User::query()->find(self::SITE_OWNER_ID);
|
||||
|
||||
// 只有 id=1 的站长账号允许通过该入口进入后台
|
||||
if (! $siteOwner || $siteOwner->username !== $validated['username']) {
|
||||
return back()
|
||||
->withInput($request->safe()->only(['username']))
|
||||
->withErrors(['username' => '该入口仅限站长账号使用。']);
|
||||
}
|
||||
|
||||
if (! $this->passwordMatches($siteOwner, $validated['password'])) {
|
||||
return back()
|
||||
->withInput($request->safe()->only(['username']))
|
||||
->withErrors(['password' => '账号或密码错误。']);
|
||||
}
|
||||
|
||||
// 若当前已有其他账号占用会话,先退出后再切换为站长会话
|
||||
if (Auth::check() && Auth::id() !== $siteOwner->id) {
|
||||
Auth::logout();
|
||||
}
|
||||
|
||||
Auth::login($siteOwner);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->put('admin_login_via_hidden', true);
|
||||
|
||||
// 复用主登录的会话登记逻辑,保证后台入口也会更新登录痕迹
|
||||
$this->recordAdminLogin($siteOwner, (string) $request->ip());
|
||||
|
||||
return redirect()->route('admin.dashboard')->with('success', '站长后台登录成功。');
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验站长密码,兼容旧库 MD5 并自动升级为 bcrypt。
|
||||
*/
|
||||
private function passwordMatches(User $siteOwner, string $plainPassword): bool
|
||||
{
|
||||
try {
|
||||
if (Hash::check($plainPassword, $siteOwner->password)) {
|
||||
return true;
|
||||
}
|
||||
} catch (\RuntimeException $exception) {
|
||||
// 旧库非 bcrypt 密码会在这里抛异常,后续继续走 MD5 兼容逻辑
|
||||
}
|
||||
|
||||
if (md5($plainPassword) !== $siteOwner->password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 兼容老密码登录成功后,立即升级为 Laravel 默认哈希
|
||||
$siteOwner->forceFill([
|
||||
'password' => Hash::make($plainPassword),
|
||||
])->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录站长通过隐藏入口登录后的访问痕迹。
|
||||
*/
|
||||
private function recordAdminLogin(User $siteOwner, string $ip): void
|
||||
{
|
||||
// 登录成功后补齐访问次数、IP 与时间,保持与前台登录统计一致
|
||||
$siteOwner->increment('visit_num');
|
||||
$siteOwner->update([
|
||||
'previous_ip' => $siteOwner->last_ip,
|
||||
'last_ip' => $ip,
|
||||
'log_time' => now(),
|
||||
'in_time' => now(),
|
||||
]);
|
||||
|
||||
\App\Models\IpLog::create([
|
||||
'ip' => $ip,
|
||||
'sdate' => now(),
|
||||
'uuname' => $siteOwner->username,
|
||||
]);
|
||||
|
||||
try {
|
||||
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
|
||||
$wechatService->notifyAdminOnline($siteOwner);
|
||||
$wechatService->notifyFriendsOnline($siteOwner);
|
||||
$wechatService->notifySpouseOnline($siteOwner);
|
||||
} catch (\Exception $exception) {
|
||||
// 机器人通知异常不影响站长进入后台,但需要落日志便于排查
|
||||
Log::error('Hidden admin login notification failed', ['error' => $exception->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:站长隐藏登录请求验证器
|
||||
*
|
||||
* 仅校验站长登录页提交的账号、密码与验证码字段,
|
||||
* 不参与聊天室前台“登录即注册”流程。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 类功能:校验站长隐藏登录表单。
|
||||
*/
|
||||
class AdminLoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前请求是否允许继续处理。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取站长隐藏登录所需的验证规则。
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => ['required', 'string', 'max:255'],
|
||||
'password' => ['required', 'string', 'min:1'],
|
||||
'captcha' => ['required', 'captcha'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证失败时展示的中文提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'username.required' => '必须填写站长账号。',
|
||||
'password.required' => '必须填写登录密码。',
|
||||
'password.min' => '登录密码格式不正确。',
|
||||
'captcha.required' => '必须填写验证码。',
|
||||
'captcha.captcha' => '验证码不正确。',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
{{-- 文件功能:站长隐藏登录页 --}}
|
||||
@php
|
||||
$systemName = \App\Models\SysParam::where('alias', 'sys_name')->value('body') ?? '和平聊吧';
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>站长后台入口 - {{ $systemName }}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700;900&family=Noto+Serif+SC:wght@700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fafaf9;
|
||||
--paper: #f5f1e8;
|
||||
--ink: #0c0a09;
|
||||
--ink-soft: #57534e;
|
||||
--ink-faint: #a8a29e;
|
||||
--line: #d6d3d1;
|
||||
--line-strong: #1c1917;
|
||||
--panel: #111111;
|
||||
--panel-soft: #1f1f1f;
|
||||
--gold: #a16207;
|
||||
--gold-soft: #f4d9a8;
|
||||
--danger: #b91c1c;
|
||||
--danger-bg: #fef2f2;
|
||||
--success: #166534;
|
||||
--success-bg: #f0fdf4;
|
||||
--shadow: 0 28px 90px rgba(12, 10, 9, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "DM Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(161, 98, 7, 0.08), transparent 18%),
|
||||
linear-gradient(180deg, #fcfbf8 0%, #f7f4ee 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1380px, calc(100% - 32px));
|
||||
margin: 16px auto;
|
||||
min-height: calc(100vh - 32px);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--bg);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(420px, 0.9fr);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 18px;
|
||||
border: 1px solid rgba(28, 25, 23, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 38px 42px 34px;
|
||||
border-right: 1px solid var(--line-strong);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 30px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(161, 98, 7, 0.03), transparent 26%),
|
||||
linear-gradient(90deg, transparent 0, transparent calc(100% - 1px), rgba(28, 25, 23, 0.04) calc(100% - 1px));
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.eyebrow::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold);
|
||||
}
|
||||
|
||||
.system-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hero-main {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: clamp(4rem, 10vw, 8.8rem);
|
||||
line-height: 0.88;
|
||||
letter-spacing: -0.06em;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title-accent {
|
||||
display: block;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 620px;
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 18px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.rule {
|
||||
width: 92px;
|
||||
height: 6px;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
padding: 20px 18px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
min-height: 152px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
margin-top: 14px;
|
||||
font-size: clamp(1.9rem, 3vw, 3.2rem);
|
||||
line-height: 0.95;
|
||||
font-weight: 900;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.meta-copy {
|
||||
margin-top: 12px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
color: #fff;
|
||||
padding: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
width: min(100%, 470px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.015)),
|
||||
var(--panel);
|
||||
padding: 28px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel-top {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.panel-kicker {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold-soft);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 10px 0 8px;
|
||||
font-family: "Noto Serif SC", serif;
|
||||
font-size: 34px;
|
||||
line-height: 1.12;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.panel-copy {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 15px;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #fff;
|
||||
padding: 16px 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
outline: none;
|
||||
transition: border-color .2s ease, background-color .2s ease, box-shadow .2s ease;
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.36);
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: rgba(244, 217, 168, 0.9);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 0 0 3px rgba(161, 98, 7, 0.22);
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 148px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.message-success {
|
||||
border-color: rgba(34, 197, 94, 0.34);
|
||||
background: rgba(22, 101, 52, 0.18);
|
||||
color: #dcfce7;
|
||||
}
|
||||
|
||||
.message-error {
|
||||
border-color: rgba(248, 113, 113, 0.34);
|
||||
background: rgba(127, 29, 29, 0.16);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
border: 1px solid var(--gold);
|
||||
background: linear-gradient(180deg, #d5a548 0%, #a16207 100%);
|
||||
color: #fff;
|
||||
padding: 17px 18px;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: transform .2s ease, filter .2s ease, box-shadow .2s ease;
|
||||
box-shadow: 0 18px 28px rgba(161, 98, 7, 0.2);
|
||||
}
|
||||
|
||||
.submit:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: saturate(1.03);
|
||||
box-shadow: 0 22px 34px rgba(161, 98, 7, 0.28);
|
||||
}
|
||||
|
||||
.helper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.helper strong {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shell {
|
||||
width: min(100%, calc(100% - 18px));
|
||||
margin: 9px auto;
|
||||
min-height: calc(100vh - 18px);
|
||||
}
|
||||
|
||||
.shell::before {
|
||||
inset: 10px;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.hero-main {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(3rem, 20vw, 4.8rem);
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-top,
|
||||
.captcha-row {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<header class="topbar">
|
||||
<div class="eyebrow">Hidden Admin Entry</div>
|
||||
<div class="system-name">{{ $systemName }}</div>
|
||||
</header>
|
||||
|
||||
<div class="hero-main">
|
||||
<div class="kicker">Trust / Authority / Private Access</div>
|
||||
<h1 class="title">
|
||||
Owner
|
||||
<span class="title-accent">Console</span>
|
||||
</h1>
|
||||
<div class="rule" aria-hidden="true"></div>
|
||||
<p class="lead">
|
||||
这是一个独立于聊天室首页的后台登录入口。这里只做站长控制台访问,不承载普通用户登录,不会在登录成功后打开聊天室页面。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-meta">
|
||||
<article class="meta-card">
|
||||
<div class="meta-label">入口后缀</div>
|
||||
<div class="meta-value">{{ $loginSuffix }}</div>
|
||||
<div class="meta-copy">保留隐藏后缀,不在首页暴露。这个入口只用于后台控制台访问。</div>
|
||||
</article>
|
||||
|
||||
<article class="meta-card">
|
||||
<div class="meta-label">权限约束</div>
|
||||
<div class="meta-value">id=1</div>
|
||||
<div class="meta-copy">只有站长主账号可以从这里登录,其他账号即使密码正确也会被拒绝。</div>
|
||||
</article>
|
||||
|
||||
<article class="meta-card">
|
||||
<div class="meta-label">登录去向</div>
|
||||
<div class="meta-value">Admin</div>
|
||||
<div class="meta-copy">验证通过后直接进入后台首页,不跳聊天室大厅,不走“登录即注册”。</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-card">
|
||||
<div class="panel-top">
|
||||
<div>
|
||||
<div class="panel-kicker">Owner Authentication</div>
|
||||
<h2 class="panel-title">登录后台控制台</h2>
|
||||
<p class="panel-copy">输入站长账号、密码和验证码后进入后台。</p>
|
||||
</div>
|
||||
<div class="status-tag">Restricted</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('admin.login.store') }}" class="form">
|
||||
@csrf
|
||||
|
||||
<div class="field">
|
||||
<label for="username">站长账号</label>
|
||||
<input id="username" name="username" type="text" value="{{ old('username') }}"
|
||||
placeholder="请输入 id=1 对应的用户名" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">登录密码</label>
|
||||
<input id="password" name="password" type="password" placeholder="请输入站长密码"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="captcha">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<input id="captcha" name="captcha" type="text" placeholder="输入验证码" required>
|
||||
<img src="/captcha/default?{{ mt_rand() }}" alt="站长登录验证码" id="captcha-img" class="captcha-image"
|
||||
onclick="refreshCaptcha()" title="点击刷新验证码">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="message message-success" role="status" aria-live="polite">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="message message-error" role="alert" aria-live="assertive">
|
||||
<ul style="margin: 0; padding-left: 18px;">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button type="submit" class="submit">进入后台控制台</button>
|
||||
</form>
|
||||
|
||||
<div class="helper">
|
||||
<strong>提示:</strong>点击右侧验证码图片可刷新。如果页面仍显示旧样式,直接强制刷新浏览器缓存即可。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 刷新验证码图片,避免浏览器缓存旧图。
|
||||
*/
|
||||
function refreshCaptcha() {
|
||||
document.getElementById('captcha-img').src = '/captcha/default?' + Math.random();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\AdminAuthController;
|
||||
use App\Http\Controllers\AdminCommandController;
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\ChangelogController;
|
||||
@@ -15,6 +16,11 @@ use Illuminate\Support\Facades\Route;
|
||||
// 聊天室首页 (即登录/注册页面)
|
||||
Route::get('/', function () {
|
||||
if (Auth::check()) {
|
||||
// 通过隐藏入口登录的站长,首页统一回到后台而不是聊天室大厅
|
||||
if (Auth::id() === 1 && session('admin_login_via_hidden')) {
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
|
||||
return redirect()->route('rooms.index');
|
||||
}
|
||||
|
||||
@@ -24,6 +30,10 @@ Route::get('/', function () {
|
||||
return view('index', compact('rooms'));
|
||||
})->name('home');
|
||||
|
||||
// 站长隐藏登录入口(仅 id=1 可使用,成功后直接进入后台控制台)
|
||||
Route::get('/lkddi', [AdminAuthController::class, 'create'])->name('admin.login');
|
||||
Route::post('/lkddi', [AdminAuthController::class, 'store'])->name('admin.login.store');
|
||||
|
||||
// 处理登录/自动注册请求
|
||||
Route::post('/login', [AuthController::class, 'login'])->name('login.post');
|
||||
|
||||
|
||||
@@ -45,6 +45,21 @@ class ChatControllerTest extends TestCase
|
||||
$this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试主干默认聊天室页面不会渲染虚拟形象挂载点和配置。
|
||||
*/
|
||||
public function test_room_view_does_not_render_avatar_widget_or_config_by_default(): void
|
||||
{
|
||||
$room = Room::create(['room_name' => 'avguard']);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertDontSee('chat-avatar-widget');
|
||||
$response->assertDontSee('chatAvatarWidget');
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用户可以发送普通文本消息。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:站长隐藏登录功能测试
|
||||
*
|
||||
* 覆盖隐藏登录页访问、站长登录成功、非站长拒绝登录
|
||||
* 以及通过隐藏入口登录后首页不再回到聊天室大厅等核心场景。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
namespace Tests\Feature\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证站长隐藏登录入口的行为。
|
||||
*/
|
||||
class AdminAuthControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 测试前注册验证码校验桩,避免依赖真实验证码生成。
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Validator::extend('captcha', function () {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证隐藏登录页可以正常打开。
|
||||
*/
|
||||
public function test_hidden_admin_login_page_can_be_opened(): void
|
||||
{
|
||||
$response = $this->get('/lkddi');
|
||||
|
||||
$response->assertOk()
|
||||
->assertSee('站长后台入口')
|
||||
->assertSee('/lkddi');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 id=1 站长可以通过隐藏入口登录并进入后台首页。
|
||||
*/
|
||||
public function test_site_owner_can_login_via_hidden_admin_entry(): void
|
||||
{
|
||||
$siteOwner = User::factory()->create([
|
||||
'id' => 1,
|
||||
'username' => 'site-owner',
|
||||
'password' => Hash::make('secret-owner'),
|
||||
]);
|
||||
|
||||
$response = $this->post('/lkddi', [
|
||||
'username' => 'site-owner',
|
||||
'password' => 'secret-owner',
|
||||
'captcha' => '1234',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.dashboard'));
|
||||
$response->assertSessionHas('admin_login_via_hidden', true);
|
||||
$this->assertAuthenticatedAs($siteOwner);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非 id=1 账号即使密码正确,也不能使用隐藏入口。
|
||||
*/
|
||||
public function test_non_site_owner_cannot_login_via_hidden_admin_entry(): void
|
||||
{
|
||||
User::factory()->create([
|
||||
'username' => 'manager',
|
||||
'password' => Hash::make('secret-manager'),
|
||||
]);
|
||||
|
||||
$response = $this->from('/lkddi')->post('/lkddi', [
|
||||
'username' => 'manager',
|
||||
'password' => 'secret-manager',
|
||||
'captcha' => '1234',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/lkddi');
|
||||
$response->assertSessionHasErrors('username');
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证通过隐藏入口登录的站长访问首页时不会被送去聊天室大厅。
|
||||
*/
|
||||
public function test_hidden_admin_login_keeps_homepage_redirecting_to_dashboard(): void
|
||||
{
|
||||
$siteOwner = User::factory()->create([
|
||||
'id' => 1,
|
||||
'username' => 'site-owner',
|
||||
'password' => Hash::make('secret-owner'),
|
||||
]);
|
||||
|
||||
$this->actingAs($siteOwner)->withSession([
|
||||
'admin_login_via_hidden' => true,
|
||||
]);
|
||||
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertRedirect(route('admin.dashboard'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user