feat: 管理界面改为 token 登录门禁

此前不填 token 也能进入各页面,只是没有内容。新增受鉴权保护的
/api/auth/session 探针与登录页,路由守卫在进入任何非登录页前先探针,
未通过则跳登录页并记住原目标;接口返回 401 时自动清 token 回登录页;
顶栏加退出按钮,撤掉设置页里的 token 卡片。本机未设 ADMIN_TOKEN 时
探针直接通过,开发体验不变。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:38:38 +08:00
parent 43a753963c
commit a3072ccbfb
7 changed files with 244 additions and 19 deletions

View File

@@ -22,6 +22,8 @@ const app = new Hono();
app.get('/api/health', (c) => c.json({ ok: true, name: 'proxy-station' }));
app.use('/api/*', adminAuth);
// 鉴权探针:能到这里说明已通过 adminAuthtoken 正确,或未设 token 且来源为本机/局域网)
app.get('/api/auth/session', (c) => c.json({ ok: true, tokenRequired: !!env.adminToken }));
app.route('/api/nodes', nodesRoute);
app.route('/api/groups', groupsRoute);
app.route('/api/rules', rulesRoute);

View File

@@ -3,9 +3,23 @@ import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui';
import { themeOverrides } from './theme';
import { useAuthStore } from './stores/auth';
import { UNAUTHORIZED_EVENT } from './api/client';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
// 任何接口回 401本地 token 已失效,清掉并回登录页
window.addEventListener(UNAUTHORIZED_EVENT, () => {
auth.invalidate();
if (!route.meta.public) router.replace({ path: '/login', query: { redirect: route.fullPath } });
});
function logout() {
auth.logout();
router.replace('/login');
}
const navItems = [
{ path: '/nodes', label: '节点' },
@@ -22,7 +36,8 @@ const active = computed(() => route.path);
<NConfigProvider :theme-overrides="themeOverrides">
<NMessageProvider>
<NDialogProvider>
<div class="shell">
<RouterView v-if="route.meta.public" />
<div v-else class="shell">
<header class="topbar">
<div class="topbar-inner">
<div class="brand">
@@ -43,6 +58,7 @@ const active = computed(() => route.path);
<div class="legend">
<span class="legend-item"><i class="lamp lamp-direct" />直连出口</span>
<span class="legend-item"><i class="lamp lamp-warp" />WARP 出口</span>
<button v-if="auth.tokenRequired" class="logout" @click="logout">退出</button>
</div>
</div>
</header>
@@ -156,6 +172,22 @@ const active = computed(() => route.path);
white-space: nowrap;
}
.logout {
appearance: none;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
font: 600 12px var(--font-body);
padding: 4px 12px;
border-radius: 999px;
cursor: pointer;
}
.logout:hover {
color: var(--text);
border-color: var(--text);
}
.content {
max-width: 1240px;
margin: 0 auto;
@@ -178,7 +210,7 @@ const active = computed(() => route.path);
overflow-x: auto;
}
.legend {
.legend-item {
display: none;
}

View File

@@ -1,4 +1,5 @@
const ADMIN_TOKEN_KEY = 'proxy-station:admin-token';
export const UNAUTHORIZED_EVENT = 'proxy-station:unauthorized';
export function getAdminToken(): string {
return localStorage.getItem(ADMIN_TOKEN_KEY) || '';
@@ -30,6 +31,8 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
});
const data = await res.json().catch(() => null);
if (!res.ok) {
// token 失效或被更换:广播给路由层回登录页。用事件而不是直接 import router避免 client → router → store → client 的循环依赖
if (res.status === 401 && !path.startsWith('/api/auth/')) window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT));
throw new ApiError(res.status, (data as any)?.error || `请求失败(${res.status}`);
}
return data as T;

127
web/src/pages/LoginPage.vue Normal file
View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NButton, NInput } from 'naive-ui';
import { useAuthStore } from '../stores/auth';
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const token = ref('');
const busy = ref(false);
const error = ref(auth.lastError);
async function submit() {
if (!token.value.trim() || busy.value) return;
busy.value = true;
error.value = '';
const ok = await auth.login(token.value);
busy.value = false;
if (ok) {
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
router.replace(redirect.startsWith('/') ? redirect : '/');
} else {
error.value = auth.lastError || 'token 不正确';
token.value = '';
}
}
</script>
<template>
<div class="gate">
<form class="card" @submit.prevent="submit">
<div class="brand">
<span class="brand-name display">Proxy Station</span>
<span class="brand-strip"><i class="strip-direct" /><i class="strip-warp" /></span>
</div>
<p class="label">管理入口</p>
<p class="hint">填入服务端 <code>ADMIN_TOKEN</code> 进入token 保存在本浏览器退出时清除</p>
<NInput
v-model:value="token"
type="password"
show-password-on="click"
placeholder="ADMIN_TOKEN"
class="mono"
size="large"
:disabled="busy"
autofocus
@keyup.enter="submit"
/>
<p v-if="error" class="error" role="alert">{{ error }}</p>
<NButton type="primary" size="large" block attr-type="submit" :loading="busy" :disabled="!token.trim()">进入</NButton>
</form>
</div>
</template>
<style scoped>
.gate {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.card {
width: 100%;
max-width: 380px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 28px 28px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.brand {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.brand-name {
font-size: 20px;
}
.brand-strip {
display: flex;
height: 3px;
width: 72px;
border-radius: 2px;
overflow: hidden;
}
.brand-strip i {
flex: 1;
}
.strip-direct {
background: var(--direct);
}
.strip-warp {
background: var(--warp);
}
.hint {
margin: 0;
font-size: 13px;
color: var(--muted);
line-height: 1.6;
}
.hint code {
font-family: var(--font-mono);
font-size: 12px;
}
.error {
margin: 0;
font-size: 13px;
color: var(--danger);
}
</style>

View File

@@ -1,14 +1,12 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { NButton, NCard, NInput, NSpace, NTabPane, NTabs, useMessage } from 'naive-ui';
import { getAdminToken, setAdminToken } from '../api/client';
import { NButton, NCard, NInput, NTabPane, NTabs, useMessage } from 'naive-ui';
import { useSubscriptionsStore } from '../stores/subscriptions';
const store = useSubscriptionsStore();
const message = useMessage();
const sections = ref<Record<string, string>>({});
const adminToken = ref(getAdminToken());
const activeSection = ref('General');
onMounted(async () => {
@@ -29,26 +27,12 @@ async function saveSections() {
message.success('已保存');
}
function saveAdminToken() {
setAdminToken(adminToken.value.trim());
message.success(adminToken.value.trim() ? '已保存到本浏览器' : '已清除');
}
</script>
<template>
<div>
<h1 class="display page-title">设置</h1>
<NCard title="管理鉴权" size="small" class="block">
<p class="hint">
服务端设置了 ADMIN_TOKEN 环境变量时管理接口需要携带同样的 token此处保存的值仅存在本浏览器 localStorage
</p>
<NSpace>
<NInput v-model:value="adminToken" type="password" show-password-on="click" placeholder="ADMIN_TOKEN" class="mono" style="width: 320px" />
<NButton @click="saveAdminToken">保存</NButton>
</NSpace>
</NCard>
<NCard title="Surge 原文段落" size="small" class="block">
<p class="hint">
这些段落按原文透传进 Surge 配置[General][MITM] 此处直接编辑文本ShadowRocketClashMeta 不使用这些内容

View File

@@ -1,13 +1,28 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from './stores/auth';
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/nodes' },
{ path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { title: '登录', public: true } },
{ path: '/nodes', component: () => import('./pages/NodesPage.vue'), meta: { title: '节点' } },
{ path: '/groups', component: () => import('./pages/GroupsPage.vue'), meta: { title: '策略组' } },
{ path: '/rules', component: () => import('./pages/RulesPage.vue'), meta: { title: '规则' } },
{ path: '/subscriptions', component: () => import('./pages/SubscriptionsPage.vue'), meta: { title: '订阅' } },
{ path: '/settings', component: () => import('./pages/SettingsPage.vue'), meta: { title: '设置' } },
{ path: '/:pathMatch(.*)*', redirect: '/nodes' },
],
});
// 任何非公开页面进入前都要先通过一次探针;直接在地址栏敲子页面路径同样会被拦到登录页
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (to.meta.public) {
if (auth.status === 'unknown') await auth.check();
return auth.status === 'ok' ? { path: '/' } : true;
}
if (auth.status !== 'ok') await auth.check();
if (auth.status === 'ok') return true;
return { path: '/login', query: to.fullPath !== '/' ? { redirect: to.fullPath } : {} };
});

62
web/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,62 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { api, ApiError, getAdminToken, setAdminToken } from '../api/client';
interface SessionDto {
ok: boolean;
tokenRequired: boolean;
}
/**
* 登录态只存在内存里:每次刷新都重新探针一次 /api/auth/session。
* token 本身仍存 localStorage刷新后免重登探针失败就清掉它。
*/
export const useAuthStore = defineStore('auth', () => {
const status = ref<'unknown' | 'ok' | 'denied'>('unknown');
const tokenRequired = ref(false);
const lastError = ref('');
async function check(): Promise<boolean> {
try {
const s = await api.get<SessionDto>('/api/auth/session');
tokenRequired.value = s.tokenRequired;
status.value = 'ok';
lastError.value = '';
return true;
} catch (e) {
status.value = 'denied';
if (e instanceof ApiError) {
// 401 = 需要 token403 = 服务端未设 token 且来源不是本机/局域网,填 token 也无济于事
tokenRequired.value = e.status === 401;
lastError.value = e.status === 401 ? '' : e.message;
} else {
lastError.value = '无法连接服务端';
}
return false;
}
}
async function login(token: string): Promise<boolean> {
setAdminToken(token.trim());
const ok = await check();
if (!ok) {
setAdminToken('');
if (tokenRequired.value) lastError.value = 'token 不正确';
}
return ok;
}
function logout() {
setAdminToken('');
status.value = 'denied';
}
/** 收到任何接口的 401 时调用:本地 token 已失效,回到未登录态 */
function invalidate() {
if (getAdminToken()) setAdminToken('');
status.value = 'denied';
tokenRequired.value = true;
}
return { status, tokenRequired, lastError, check, login, logout, invalidate };
});