feat(auth): 内置账号密码登录,Docker 部署可通过环境变量覆盖

- 后端读取 VCM_USERNAME/VCM_PASSWORD(默认 admin/admin123),新增 /api/login、/api/logout,Bearer token 中间件保护全部 /api/*(登录/登出除外)
- 前端登录接真实接口,token 存 localStorage(记住我)/ sessionStorage;401 自动清除会话跳登录页
- 修复 App.vue 初始路由未就绪导致登录页误触发数据加载;退出登录先清会话再跳转
- docker-compose 增加 VCM_USERNAME/VCM_PASSWORD 环境变量(可用宿主机 .env 覆盖)
This commit is contained in:
陈银军
2026-09-19 22:49:32 +08:00
parent 735eaa7bf4
commit 02ac744e13
9 changed files with 130 additions and 36 deletions
+34
View File
@@ -6,10 +6,13 @@
* VERDACCIO_STORAGE Verdaccio 缓存目录(优先于 UI 设置) * VERDACCIO_STORAGE Verdaccio 缓存目录(优先于 UI 设置)
* PORT 监听端口(默认 3000) * PORT 监听端口(默认 3000)
* DATA_DIR SQLite 数据目录(默认 backend/data) * DATA_DIR SQLite 数据目录(默认 backend/data)
* VCM_USERNAME 面板登录账号(默认 admin)
* VCM_PASSWORD 面板登录密码(默认 admin123,Docker 部署务必通过 compose 覆盖)
*/ */
import express from 'express' import express from 'express'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs' import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import { execSync } from 'node:child_process' import { execSync } from 'node:child_process'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { import {
@@ -163,6 +166,37 @@ function buildPackages(keyword = '') {
return list return list
} }
// —— 登录鉴权:内置账号(Docker 部署通过 VCM_USERNAME/VCM_PASSWORD 环境变量覆盖) ——
const AUTH_USERNAME = process.env.VCM_USERNAME || 'admin'
const AUTH_PASSWORD = process.env.VCM_PASSWORD || 'admin123'
const sessions = new Map() // token -> { name, at };内存态,进程重启后需重新登录
app.post('/api/login', (req, res) => {
const { username, password } = req.body || {}
if (String(username || '') !== AUTH_USERNAME || String(password || '') !== AUTH_PASSWORD) {
return res.status(401).json({ ok: false, message: '用户名或密码错误' })
}
const token = randomUUID()
sessions.set(token, { name: AUTH_USERNAME, at: Date.now() })
res.json({ ok: true, token, user: { name: AUTH_USERNAME } })
})
app.post('/api/logout', (req, res) => {
const auth = req.headers.authorization || ''
if (auth.startsWith('Bearer ')) sessions.delete(auth.slice(7))
res.json({ ok: true })
})
// 保护全部 /api/*(/login、/logout 除外):未登录返回 401,前端据此跳转登录页
app.use('/api', (req, res, next) => {
if (req.path === '/login' || req.path === '/logout') return next()
const auth = req.headers.authorization || ''
if (!auth.startsWith('Bearer ') || !sessions.has(auth.slice(7))) {
return res.status(401).json({ ok: false, message: '未登录或会话已失效' })
}
next()
})
// —— API:阈值 —— // —— API:阈值 ——
// spec §4.2:GET/POST /api/threshold(版本阈值) // spec §4.2:GET/POST /api/threshold(版本阈值)
app.get('/api/threshold', (req, res) => { app.get('/api/threshold', (req, res) => {
+2
View File
@@ -11,6 +11,8 @@ services:
- VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方挂载的右侧 - VERDACCIO_STORAGE=/verdaccio/storage # 容器内路径,对应下方挂载的右侧
- PORT=3000 - PORT=3000
- TZ=Asia/Shanghai # 每日 00:00 自动扫描按北京时间执行 - TZ=Asia/Shanghai # 每日 00:00 自动扫描按北京时间执行
- VCM_USERNAME=${VCM_USERNAME:-admin} # 面板登录账号(可在宿主机 .env 覆盖)
- VCM_PASSWORD=${VCM_PASSWORD:-admin123} # 面板登录密码(务必改为强密码)
volumes: volumes:
- "/volume2/docker/verdaccio_verdaccio-1/verdaccio_storage:/verdaccio/storage" # Verdaccio 缓存目录(读写,清理需要) - "/volume2/docker/verdaccio_verdaccio-1/verdaccio_storage:/verdaccio/storage" # Verdaccio 缓存目录(读写,清理需要)
- "vcm-data:/app/data" # 读写:SQLite 数据(named volume,避免宿主目录权限问题) - "vcm-data:/app/data" # 读写:SQLite 数据(named volume,避免宿主目录权限问题)
+5 -6
View File
@@ -1,12 +1,13 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import AppTopbar from './components/AppTopbar.vue' import AppTopbar from './components/AppTopbar.vue'
import MobileTabbar from './components/MobileTabbar.vue' import MobileTabbar from './components/MobileTabbar.vue'
import SettingDrawer from './components/SettingDrawer.vue' import SettingDrawer from './components/SettingDrawer.vue'
import { useStore } from './store' import { useStore } from './store'
const route = useRoute() const route = useRoute()
const router = useRouter()
const { state, refreshAll, scanNow } = useStore() const { state, refreshAll, scanNow } = useStore()
const settingsOpen = ref(false) const settingsOpen = ref(false)
const isLogin = () => route.path === '/login' const isLogin = () => route.path === '/login'
@@ -22,9 +23,11 @@ function onFocusOut() {
if (!a || (a.tagName !== 'INPUT' && a.tagName !== 'TEXTAREA')) kbOpen.value = false if (!a || (a.tagName !== 'INPUT' && a.tagName !== 'TEXTAREA')) kbOpen.value = false
}, 120) }, 120)
} }
onMounted(() => { onMounted(async () => {
document.addEventListener('focusin', onFocusIn) document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut) document.addEventListener('focusout', onFocusOut)
// 等待初始路由导航完成后再判断页面,避免登录页上误触发数据加载(此时 route.path 仍是 /)
await router.isReady()
if (!isLogin()) refreshAll() if (!isLogin()) refreshAll()
}) })
@@ -48,10 +51,6 @@ const fmtScanBytes = computed(() => {
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB` if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
return `${mb.toFixed(1)} MB` return `${mb.toFixed(1)} MB`
}) })
onMounted(() => {
if (!isLogin()) refreshAll()
})
</script> </script>
<template> <template>
+71 -13
View File
@@ -7,6 +7,31 @@ import * as mock from '../mock/data.js'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false' const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
// —— 登录会话:token 存 localStorage(记住我)或 sessionStorage(关浏览器失效) ——
const TOKEN_KEY = 'vcm-token'
const USER_KEY = 'vcm-user'
function getToken() {
return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY) || ''
}
/** 统一请求封装:自动附带 Bearer token;401 时清除会话并跳转登录页 */
async function authFetch(url, options = {}) {
const token = getToken()
const r = await fetch(url, {
...options,
headers: { ...(options.headers || {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) },
})
if (r.status === 401) {
localStorage.removeItem(TOKEN_KEY)
sessionStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
if (!location.pathname.startsWith('/login')) location.href = '/login'
throw new Error('未登录或会话已失效')
}
return r
}
// —— mock 内存态(模拟服务端持久化) —— // —— mock 内存态(模拟服务端持久化) ——
const mockState = reactive({ const mockState = reactive({
versionThreshold: 15, versionThreshold: 15,
@@ -43,12 +68,45 @@ function computeStatus(p, threshold) {
} }
const api = { const api = {
/** 登录:校验后端内置账号(Docker 部署由 VCM_USERNAME/VCM_PASSWORD 决定);remember 决定 token 存 localStorage 还是 sessionStorage */
async login(username, password, remember = true) {
if (USE_MOCK) {
await delay(400)
if (username !== 'admin' || password !== 'admin123') return { ok: false, message: '用户名或密码错误' }
const token = `mock-${Date.now()}`
;(remember ? localStorage : sessionStorage).setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify({ name: username, email: `${username}@verdaccio.local` }))
return { ok: true, user: { name: username } }
}
// 登录接口公开:错误密码的 401 是业务响应,不能用 authFetch(会误判为会话失效并抛异常)
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
const data = await r.json().catch(() => ({}))
if (!r.ok || !data.ok) return { ok: false, message: data.message || '登录失败' }
;(remember ? localStorage : sessionStorage).setItem(TOKEN_KEY, data.token)
localStorage.setItem(USER_KEY, JSON.stringify({ name: data.user?.name || username }))
return { ok: true, user: data.user }
},
/** 退出登录:通知后端销毁会话并清除本地 token */
async logout() {
try {
if (!USE_MOCK) await authFetch('/api/logout', { method: 'POST' })
} catch { /* 忽略网络错误,本地清除即可 */ }
localStorage.removeItem(TOKEN_KEY)
sessionStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
},
async getSummary() { async getSummary() {
if (USE_MOCK) { if (USE_MOCK) {
await delay() await delay()
return mock.summary() return mock.summary()
} }
const r = await fetch('/api/packages') const r = await authFetch('/api/packages')
const { packages } = await r.json() const { packages } = await r.json()
const valid = packages.filter(p => p.status !== 'broken') const valid = packages.filter(p => p.status !== 'broken')
const warnCount = valid.filter(p => p.status === 'warn').length const warnCount = valid.filter(p => p.status === 'warn').length
@@ -69,7 +127,7 @@ const api = {
if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw)) if (kw) list = list.filter(p => p.name.toLowerCase().includes(kw))
return { packages: sortPackages(list), scannedAt: new Date().toISOString(), scanning: false } return { packages: sortPackages(list), scannedAt: new Date().toISOString(), scanning: false }
} }
const r = await fetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`) const r = await authFetch(`/api/packages?keyword=${encodeURIComponent(keyword)}`)
const data = await r.json() const data = await r.json()
return { return {
packages: data.packages || [], packages: data.packages || [],
@@ -88,7 +146,7 @@ const api = {
const p = clone(mockState.packages).find(x => x.name === name) || null const p = clone(mockState.packages).find(x => x.name === name) || null
return { ok: !!p, pkg: p } return { ok: !!p, pkg: p }
} }
const r = await fetch(`/api/packages/${encodeURIComponent(name)}`) const r = await authFetch(`/api/packages/${encodeURIComponent(name)}`)
if (!r.ok) return { ok: false, pkg: null } if (!r.ok) return { ok: false, pkg: null }
return r.json() return r.json()
}, },
@@ -99,7 +157,7 @@ const api = {
await delay(300) await delay(300)
return { ok: true, scanning: false, scannedAt: new Date().toISOString() } return { ok: true, scanning: false, scannedAt: new Date().toISOString() }
} }
const r = await fetch('/api/scan', { method: 'POST' }) const r = await authFetch('/api/scan', { method: 'POST' })
return r.json() return r.json()
}, },
@@ -108,7 +166,7 @@ const api = {
if (USE_MOCK) { if (USE_MOCK) {
return { scanning: false, done: true, packages: 33, bytes: 0 } return { scanning: false, done: true, packages: 33, bytes: 0 }
} }
const r = await fetch('/api/scan/progress') const r = await authFetch('/api/scan/progress')
return r.json() return r.json()
}, },
@@ -121,7 +179,7 @@ const api = {
exceptions: clone(mockState.exceptions), exceptions: clone(mockState.exceptions),
} }
} }
const r = await fetch('/api/thresholds') const r = await authFetch('/api/thresholds')
return r.json() return r.json()
}, },
@@ -133,7 +191,7 @@ const api = {
mockState.exceptions = clone(payload.exceptions || []) mockState.exceptions = clone(payload.exceptions || [])
return { ok: true } return { ok: true }
} }
const r = await fetch('/api/thresholds', { const r = await authFetch('/api/thresholds', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -146,7 +204,7 @@ const api = {
await delay(80) await delay(80)
return [...mockState.ignored] return [...mockState.ignored]
} }
const r = await fetch('/api/ignored') const r = await authFetch('/api/ignored')
return r.json() return r.json()
}, },
@@ -164,7 +222,7 @@ const api = {
}) })
return { ok: true } return { ok: true }
} }
const r = await fetch('/api/ignore', { const r = await authFetch('/api/ignore', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pkgName }), body: JSON.stringify({ pkgName }),
@@ -189,7 +247,7 @@ const api = {
}) })
return { ok: true } return { ok: true }
} }
const r = await fetch(`/api/ignore/${encodeURIComponent(pkgName)}`, { method: 'DELETE' }) const r = await authFetch(`/api/ignore/${encodeURIComponent(pkgName)}`, { method: 'DELETE' })
return r.json() return r.json()
}, },
@@ -221,7 +279,7 @@ const api = {
mockState.logs.unshift(log) mockState.logs.unshift(log)
return { ok: true, deletedCount: toDel.length, freedSpace: `${freed.toFixed(1)} MB`, deletedVersions: log.deletedVersions } return { ok: true, deletedCount: toDel.length, freedSpace: `${freed.toFixed(1)} MB`, deletedVersions: log.deletedVersions }
} }
const r = await fetch('/api/clean', { const r = await authFetch('/api/clean', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pkgName, keep }), body: JSON.stringify({ pkgName, keep }),
@@ -242,7 +300,7 @@ const api = {
p.status = computeStatus(p, mockState.versionThreshold) p.status = computeStatus(p, mockState.versionThreshold)
return { ok: true, freedSpace: `${v.size.toFixed(1)} MB` } return { ok: true, freedSpace: `${v.size.toFixed(1)} MB` }
} }
const r = await fetch(`/api/packages/${encodeURIComponent(pkgName)}/versions/${encodeURIComponent(version)}`, { const r = await authFetch(`/api/packages/${encodeURIComponent(pkgName)}/versions/${encodeURIComponent(version)}`, {
method: 'DELETE', method: 'DELETE',
}) })
return r.json() return r.json()
@@ -253,7 +311,7 @@ const api = {
await delay(80) await delay(80)
return clone(mockState.logs) return clone(mockState.logs)
} }
const r = await fetch('/api/logs') const r = await authFetch('/api/logs')
return r.json() return r.json()
}, },
} }
+3 -2
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import { useStore } from '../store' import { useStore } from '../store'
import api from '../api'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -25,7 +26,7 @@ function go(path) {
if (route.path !== path) router.push(path) if (route.path !== path) router.push(path)
} }
function pick(action) { async function pick(action) {
if (action === 'theme') { if (action === 'theme') {
// 主题切换即时生效,保持菜单打开以便反复对比 // 主题切换即时生效,保持菜单打开以便反复对比
toggle() toggle()
@@ -35,7 +36,7 @@ function pick(action) {
if (action === 'rescan') emit('rescan') if (action === 'rescan') emit('rescan')
else if (action === 'thresholds') router.push('/thresholds') else if (action === 'thresholds') router.push('/thresholds')
else if (action === 'logout') { else if (action === 'logout') {
localStorage.removeItem('vcm-user') await api.logout() // 先销毁后端会话并清本地 token,再跳登录页(否则守卫会因 token 仍在而重定向回首页)
router.push('/login') router.push('/login')
} }
} }
+3 -2
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import api from '../api'
const props = defineProps({ modelValue: Boolean }) const props = defineProps({ modelValue: Boolean })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -18,8 +19,8 @@ function goThresholds() {
router.push('/thresholds') router.push('/thresholds')
} }
function logout() { async function logout() {
localStorage.removeItem('vcm-user') await api.logout() // 先销毁后端会话并清本地 token,再跳登录页
close() close()
router.push('/login') router.push('/login')
} }
+1 -1
View File
@@ -26,7 +26,7 @@ const router = createRouter({
}) })
router.beforeEach((to) => { router.beforeEach((to) => {
const logged = localStorage.getItem('vcm-user') !== null const logged = localStorage.getItem('vcm-token') !== null || sessionStorage.getItem('vcm-token') !== null
if (!to.meta.public && !logged) { if (!to.meta.public && !logged) {
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
} }
+11 -12
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import api from '../api'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -12,22 +13,20 @@ const password = ref('')
const remember = ref(true) const remember = ref(true)
const loading = ref(false) const loading = ref(false)
function login() { async function login() {
if (!username.value.trim() || !password.value) { if (!username.value.trim() || !password.value) {
ElMessage.warning('请输入用户名和密码') ElMessage.warning('请输入用户名和密码')
return return
} }
loading.value = true loading.value = true
// 演示账号:任意非空即可登录(真实环境对接后端鉴权) const res = await api.login(username.value.trim(), password.value, remember.value)
setTimeout(() => { loading.value = false
localStorage.setItem('vcm-user', JSON.stringify({ if (!res.ok) {
name: username.value.trim(), ElMessage.error(res.message || '用户名或密码错误')
email: `${username.value.trim()}@verdaccio.local`, return
})) }
loading.value = false ElMessage.success('欢迎回来 👋')
ElMessage.success('欢迎回来 👋') router.push(route.query.redirect || '/')
router.push(route.query.redirect || '/')
}, 500)
} }
</script> </script>
@@ -61,7 +60,7 @@ function login() {
<button class="login-btn" :disabled="loading" @click="login"> <button class="login-btn" :disabled="loading" @click="login">
{{ loading ? '登录中…' : '🚀 登 录' }} {{ loading ? '登录中…' : '🚀 登 录' }}
</button> </button>
<div class="login-tip">演示环境:任意用户名 / 密码即可登录</div> <div class="login-tip">默认账号 admin · 密码 admin123(Docker 部署可通过环境变量修改)</div>
</div> </div>
</div> </div>
</template> </template>
Binary file not shown.