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:
@@ -22,6 +22,8 @@ const app = new Hono();
|
|||||||
app.get('/api/health', (c) => c.json({ ok: true, name: 'proxy-station' }));
|
app.get('/api/health', (c) => c.json({ ok: true, name: 'proxy-station' }));
|
||||||
|
|
||||||
app.use('/api/*', adminAuth);
|
app.use('/api/*', adminAuth);
|
||||||
|
// 鉴权探针:能到这里说明已通过 adminAuth(token 正确,或未设 token 且来源为本机/局域网)
|
||||||
|
app.get('/api/auth/session', (c) => c.json({ ok: true, tokenRequired: !!env.adminToken }));
|
||||||
app.route('/api/nodes', nodesRoute);
|
app.route('/api/nodes', nodesRoute);
|
||||||
app.route('/api/groups', groupsRoute);
|
app.route('/api/groups', groupsRoute);
|
||||||
app.route('/api/rules', rulesRoute);
|
app.route('/api/rules', rulesRoute);
|
||||||
|
|||||||
@@ -3,9 +3,23 @@ import { computed } from 'vue';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui';
|
import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui';
|
||||||
import { themeOverrides } from './theme';
|
import { themeOverrides } from './theme';
|
||||||
|
import { useAuthStore } from './stores/auth';
|
||||||
|
import { UNAUTHORIZED_EVENT } from './api/client';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
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 = [
|
const navItems = [
|
||||||
{ path: '/nodes', label: '节点' },
|
{ path: '/nodes', label: '节点' },
|
||||||
@@ -22,7 +36,8 @@ const active = computed(() => route.path);
|
|||||||
<NConfigProvider :theme-overrides="themeOverrides">
|
<NConfigProvider :theme-overrides="themeOverrides">
|
||||||
<NMessageProvider>
|
<NMessageProvider>
|
||||||
<NDialogProvider>
|
<NDialogProvider>
|
||||||
<div class="shell">
|
<RouterView v-if="route.meta.public" />
|
||||||
|
<div v-else class="shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-inner">
|
<div class="topbar-inner">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
@@ -43,6 +58,7 @@ const active = computed(() => route.path);
|
|||||||
<div class="legend">
|
<div class="legend">
|
||||||
<span class="legend-item"><i class="lamp lamp-direct" />直连出口</span>
|
<span class="legend-item"><i class="lamp lamp-direct" />直连出口</span>
|
||||||
<span class="legend-item"><i class="lamp lamp-warp" />WARP 出口</span>
|
<span class="legend-item"><i class="lamp lamp-warp" />WARP 出口</span>
|
||||||
|
<button v-if="auth.tokenRequired" class="logout" @click="logout">退出</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -156,6 +172,22 @@ const active = computed(() => route.path);
|
|||||||
white-space: nowrap;
|
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 {
|
.content {
|
||||||
max-width: 1240px;
|
max-width: 1240px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -178,7 +210,7 @@ const active = computed(() => route.path);
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend {
|
.legend-item {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const ADMIN_TOKEN_KEY = 'proxy-station:admin-token';
|
const ADMIN_TOKEN_KEY = 'proxy-station:admin-token';
|
||||||
|
export const UNAUTHORIZED_EVENT = 'proxy-station:unauthorized';
|
||||||
|
|
||||||
export function getAdminToken(): string {
|
export function getAdminToken(): string {
|
||||||
return localStorage.getItem(ADMIN_TOKEN_KEY) || '';
|
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);
|
const data = await res.json().catch(() => null);
|
||||||
if (!res.ok) {
|
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})`);
|
throw new ApiError(res.status, (data as any)?.error || `请求失败(${res.status})`);
|
||||||
}
|
}
|
||||||
return data as T;
|
return data as T;
|
||||||
|
|||||||
127
web/src/pages/LoginPage.vue
Normal file
127
web/src/pages/LoginPage.vue
Normal 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>
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import { NButton, NCard, NInput, NSpace, NTabPane, NTabs, useMessage } from 'naive-ui';
|
import { NButton, NCard, NInput, NTabPane, NTabs, useMessage } from 'naive-ui';
|
||||||
import { getAdminToken, setAdminToken } from '../api/client';
|
|
||||||
import { useSubscriptionsStore } from '../stores/subscriptions';
|
import { useSubscriptionsStore } from '../stores/subscriptions';
|
||||||
|
|
||||||
const store = useSubscriptionsStore();
|
const store = useSubscriptionsStore();
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
|
|
||||||
const sections = ref<Record<string, string>>({});
|
const sections = ref<Record<string, string>>({});
|
||||||
const adminToken = ref(getAdminToken());
|
|
||||||
const activeSection = ref('General');
|
const activeSection = ref('General');
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -29,26 +27,12 @@ async function saveSections() {
|
|||||||
message.success('已保存');
|
message.success('已保存');
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveAdminToken() {
|
|
||||||
setAdminToken(adminToken.value.trim());
|
|
||||||
message.success(adminToken.value.trim() ? '已保存到本浏览器' : '已清除');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="display page-title">设置</h1>
|
<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">
|
<NCard title="Surge 原文段落" size="small" class="block">
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
这些段落按原文透传进 Surge 配置([General]、[MITM] 等),此处直接编辑文本。ShadowRocket/ClashMeta 不使用这些内容。
|
这些段落按原文透传进 Surge 配置([General]、[MITM] 等),此处直接编辑文本。ShadowRocket/ClashMeta 不使用这些内容。
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { useAuthStore } from './stores/auth';
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', redirect: '/nodes' },
|
{ path: '/', redirect: '/nodes' },
|
||||||
|
{ path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { title: '登录', public: true } },
|
||||||
{ path: '/nodes', component: () => import('./pages/NodesPage.vue'), meta: { title: '节点' } },
|
{ path: '/nodes', component: () => import('./pages/NodesPage.vue'), meta: { title: '节点' } },
|
||||||
{ path: '/groups', component: () => import('./pages/GroupsPage.vue'), meta: { title: '策略组' } },
|
{ path: '/groups', component: () => import('./pages/GroupsPage.vue'), meta: { title: '策略组' } },
|
||||||
{ path: '/rules', component: () => import('./pages/RulesPage.vue'), meta: { title: '规则' } },
|
{ path: '/rules', component: () => import('./pages/RulesPage.vue'), meta: { title: '规则' } },
|
||||||
{ path: '/subscriptions', component: () => import('./pages/SubscriptionsPage.vue'), meta: { title: '订阅' } },
|
{ path: '/subscriptions', component: () => import('./pages/SubscriptionsPage.vue'), meta: { title: '订阅' } },
|
||||||
{ path: '/settings', component: () => import('./pages/SettingsPage.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
62
web/src/stores/auth.ts
Normal 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 = 需要 token;403 = 服务端未设 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 };
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user