fix: 修复部分资源未授权访问,删除冗余 Admin 判断逻辑

This commit is contained in:
mofeng
2026-01-29 20:16:53 +08:00
parent 9cb0dd146e
commit 78aca25722
10 changed files with 43 additions and 77 deletions

View File

@@ -40,20 +40,20 @@ pub async fn auth_middleware(
mut request: Request, mut request: Request,
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let raw_path = request.uri().path();
// When this middleware is mounted under /api, Axum strips the prefix for the inner router.
// Normalize the path so checks work whether it is mounted or not.
let path = raw_path.strip_prefix("/api").unwrap_or(raw_path);
// Check if system is initialized // Check if system is initialized
if !state.config.is_initialized() { if !state.config.is_initialized() {
// Allow access to setup endpoints when not initialized // Allow only setup-related endpoints when not initialized
let path = request.uri().path(); if is_setup_public_endpoint(path) {
if path.starts_with("/api/setup")
|| path == "/api/info"
|| path.starts_with("/") && !path.starts_with("/api/")
{
return Ok(next.run(request).await); return Ok(next.run(request).await);
} }
} }
// Public endpoints that don't require auth // Public endpoints that don't require auth
let path = request.uri().path();
if is_public_endpoint(path) { if is_public_endpoint(path) {
return Ok(next.run(request).await); return Ok(next.run(request).await);
} }
@@ -89,21 +89,14 @@ fn unauthorized_response(message: &str) -> Response {
/// Check if endpoint is public (no auth required) /// Check if endpoint is public (no auth required)
fn is_public_endpoint(path: &str) -> bool { fn is_public_endpoint(path: &str) -> bool {
// Note: paths here are relative to /api since middleware is applied before nest // Note: paths here are relative to /api since middleware is applied within the nested router
matches!( matches!(
path, path,
"/" "/"
| "/auth/login" | "/auth/login"
| "/info"
| "/health" | "/health"
| "/setup" | "/setup"
| "/setup/init" | "/setup/init"
// Also check with /api prefix for direct access
| "/api/auth/login"
| "/api/info"
| "/api/health"
| "/api/setup"
| "/api/setup/init"
) || path.starts_with("/assets/") ) || path.starts_with("/assets/")
|| path.starts_with("/static/") || path.starts_with("/static/")
|| path.ends_with(".js") || path.ends_with(".js")
@@ -112,3 +105,11 @@ fn is_public_endpoint(path: &str) -> bool {
|| path.ends_with(".png") || path.ends_with(".png")
|| path.ends_with(".svg") || path.ends_with(".svg")
} }
/// Setup-only endpoints allowed before initialization.
fn is_setup_public_endpoint(path: &str) -> bool {
matches!(
path,
"/setup" | "/setup/init" | "/devices" | "/stream/codecs"
)
}

View File

@@ -465,7 +465,6 @@ pub async fn logout(
pub struct AuthCheckResponse { pub struct AuthCheckResponse {
pub authenticated: bool, pub authenticated: bool,
pub user: Option<String>, pub user: Option<String>,
pub is_admin: bool,
} }
pub async fn auth_check( pub async fn auth_check(
@@ -481,7 +480,6 @@ pub async fn auth_check(
Json(AuthCheckResponse { Json(AuthCheckResponse {
authenticated: true, authenticated: true,
user: username, user: username,
is_admin: true,
}) })
} }

View File

@@ -32,7 +32,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/setup", get(handlers::setup_status)) .route("/setup", get(handlers::setup_status))
.route("/setup/init", post(handlers::setup_init)); .route("/setup/init", post(handlers::setup_init));
// User routes (authenticated users - both regular and admin) // Authenticated routes (all logged-in users)
let user_routes = Router::new() let user_routes = Router::new()
.route("/info", get(handlers::system_info)) .route("/info", get(handlers::system_info))
.route("/auth/logout", post(handlers::logout)) .route("/auth/logout", post(handlers::logout))
@@ -71,10 +71,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/audio/devices", get(handlers::list_audio_devices)) .route("/audio/devices", get(handlers::list_audio_devices))
// Audio WebSocket endpoint // Audio WebSocket endpoint
.route("/ws/audio", any(audio_ws_handler)) .route("/ws/audio", any(audio_ws_handler))
;
// Admin-only routes (require admin privileges)
let admin_routes = Router::new()
// Configuration management (domain-separated endpoints) // Configuration management (domain-separated endpoints)
.route("/config", get(handlers::config::get_all_config)) .route("/config", get(handlers::config::get_all_config))
.route("/config", post(handlers::update_config)) .route("/config", post(handlers::update_config))
@@ -199,11 +195,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/terminal", get(handlers::terminal::terminal_index)) .route("/terminal", get(handlers::terminal::terminal_index))
.route("/terminal/", get(handlers::terminal::terminal_index)) .route("/terminal/", get(handlers::terminal::terminal_index))
.route("/terminal/ws", get(handlers::terminal::terminal_ws)) .route("/terminal/ws", get(handlers::terminal::terminal_ws))
.route("/terminal/{*path}", get(handlers::terminal::terminal_proxy)) .route("/terminal/{*path}", get(handlers::terminal::terminal_proxy));
;
// Combine protected routes (user + admin) // Protected routes (all authenticated users)
let protected_routes = Router::new().merge(user_routes).merge(admin_routes); let protected_routes = user_routes;
// Stream endpoints (accessible with auth, but typically embedded in pages) // Stream endpoints (accessible with auth, but typically embedded in pages)
let stream_routes = Router::new() let stream_routes = Router::new()

View File

@@ -16,7 +16,7 @@ export const authApi = {
request<{ success: boolean }>('/auth/logout', { method: 'POST' }), request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
check: () => check: () =>
request<{ authenticated: boolean; user?: string; is_admin?: boolean }>('/auth/check'), request<{ authenticated: boolean; user?: string }>('/auth/check'),
changePassword: (currentPassword: string, newPassword: string) => changePassword: (currentPassword: string, newPassword: string) =>
request<{ success: boolean }>('/auth/password', { request<{ success: boolean }>('/auth/password', {

View File

@@ -52,14 +52,13 @@ const overflowMenuOpen = ref(false)
const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase()) const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase())
const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329')) const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329'))
const showMsd = computed(() => { const showMsd = computed(() => {
return props.isAdmin && !isCh9329Backend.value return !!systemStore.msd?.available && !isCh9329Backend.value
}) })
const props = defineProps<{ const props = defineProps<{
mouseMode?: 'absolute' | 'relative' mouseMode?: 'absolute' | 'relative'
videoMode?: VideoMode videoMode?: VideoMode
ttydRunning?: boolean ttydRunning?: boolean
isAdmin?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -93,18 +92,16 @@ const extensionOpen = ref(false)
<VideoConfigPopover <VideoConfigPopover
v-model:open="videoPopoverOpen" v-model:open="videoPopoverOpen"
:video-mode="props.videoMode || 'mjpeg'" :video-mode="props.videoMode || 'mjpeg'"
:is-admin="props.isAdmin"
@update:video-mode="emit('update:videoMode', $event)" @update:video-mode="emit('update:videoMode', $event)"
/> />
<!-- Audio Config - Always visible --> <!-- Audio Config - Always visible -->
<AudioConfigPopover v-model:open="audioPopoverOpen" :is-admin="props.isAdmin" /> <AudioConfigPopover v-model:open="audioPopoverOpen" />
<!-- HID Config - Always visible --> <!-- HID Config - Always visible -->
<HidConfigPopover <HidConfigPopover
v-model:open="hidPopoverOpen" v-model:open="hidPopoverOpen"
:mouse-mode="mouseMode" :mouse-mode="mouseMode"
:is-admin="props.isAdmin"
@update:mouse-mode="emit('toggleMouseMode')" @update:mouse-mode="emit('toggleMouseMode')"
/> />
@@ -125,7 +122,7 @@ const extensionOpen = ref(false)
</TooltipProvider> </TooltipProvider>
<!-- ATX Power Control - Hidden on small screens --> <!-- ATX Power Control - Hidden on small screens -->
<Popover v-if="props.isAdmin" v-model:open="atxOpen" class="hidden sm:block"> <Popover v-model:open="atxOpen" class="hidden sm:block">
<PopoverTrigger as-child> <PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"> <Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Power class="h-4 w-4" /> <Power class="h-4 w-4" />
@@ -159,8 +156,8 @@ const extensionOpen = ref(false)
<!-- Right side buttons --> <!-- Right side buttons -->
<div class="flex items-center gap-1.5 shrink-0"> <div class="flex items-center gap-1.5 shrink-0">
<!-- Extension Menu - Admin only, hidden on small screens --> <!-- Extension Menu - Hidden on small screens -->
<Popover v-if="props.isAdmin" v-model:open="extensionOpen" class="hidden lg:block"> <Popover v-model:open="extensionOpen" class="hidden lg:block">
<PopoverTrigger as-child> <PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"> <Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Cable class="h-4 w-4" /> <Cable class="h-4 w-4" />
@@ -183,8 +180,8 @@ const extensionOpen = ref(false)
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<!-- Settings - Admin only, hidden on small screens --> <!-- Settings - Hidden on small screens -->
<TooltipProvider v-if="props.isAdmin" class="hidden lg:block"> <TooltipProvider class="hidden lg:block">
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')"> <Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
@@ -270,7 +267,7 @@ const extensionOpen = ref(false)
</DropdownMenuItem> </DropdownMenuItem>
<!-- ATX - Mobile only --> <!-- ATX - Mobile only -->
<DropdownMenuItem v-if="props.isAdmin" class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false"> <DropdownMenuItem class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
<Power class="h-4 w-4 mr-2" /> <Power class="h-4 w-4 mr-2" />
{{ t('actionbar.power') }} {{ t('actionbar.power') }}
</DropdownMenuItem> </DropdownMenuItem>
@@ -291,7 +288,6 @@ const extensionOpen = ref(false)
<!-- Extension - Tablet and below --> <!-- Extension - Tablet and below -->
<DropdownMenuItem <DropdownMenuItem
v-if="props.isAdmin"
class="lg:hidden" class="lg:hidden"
:disabled="!props.ttydRunning" :disabled="!props.ttydRunning"
@click="emit('openTerminal'); overflowMenuOpen = false" @click="emit('openTerminal'); overflowMenuOpen = false"
@@ -301,7 +297,7 @@ const extensionOpen = ref(false)
</DropdownMenuItem> </DropdownMenuItem>
<!-- Settings - Tablet and below --> <!-- Settings - Tablet and below -->
<DropdownMenuItem v-if="props.isAdmin" class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false"> <DropdownMenuItem class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
<Settings class="h-4 w-4 mr-2" /> <Settings class="h-4 w-4 mr-2" />
{{ t('actionbar.settings') }} {{ t('actionbar.settings') }}
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -30,7 +30,6 @@ interface AudioDevice {
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
isAdmin?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -203,11 +202,10 @@ watch(() => props.open, (isOpen) => {
</div> </div>
</div> </div>
<!-- Device Settings (requires apply) - Admin only --> <!-- Device Settings (requires apply) -->
<template v-if="props.isAdmin"> <Separator />
<Separator />
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground"> <h5 class="text-xs font-medium text-muted-foreground">
{{ t('actionbar.audioDeviceSettings') }} {{ t('actionbar.audioDeviceSettings') }}
@@ -311,7 +309,6 @@ watch(() => props.open, (isOpen) => {
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span> <span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button> </Button>
</div> </div>
</template>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@@ -27,7 +27,6 @@ import { useSystemStore } from '@/stores/system'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
mouseMode?: 'absolute' | 'relative' mouseMode?: 'absolute' | 'relative'
isAdmin?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -304,11 +303,10 @@ watch(() => props.open, (isOpen) => {
</div> </div>
</div> </div>
<!-- HID Device Settings (Requires Apply) - Admin only --> <!-- HID Device Settings (Requires Apply) -->
<template v-if="props.isAdmin"> <Separator />
<Separator />
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.hidDeviceSettings') }}</h5> <h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.hidDeviceSettings') }}</h5>
<Button <Button
@@ -393,8 +391,7 @@ watch(() => props.open, (isOpen) => {
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" /> <Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span> <span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button> </Button>
</div> </div>
</template>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@@ -43,7 +43,6 @@ interface VideoDevice {
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
videoMode: VideoMode videoMode: VideoMode
isAdmin?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -619,9 +618,7 @@ watch(currentConfig, () => {
</div> </div>
</div> </div>
<!-- Settings Link - Admin only -->
<Button <Button
v-if="props.isAdmin"
variant="ghost" variant="ghost"
size="sm" size="sm"
class="w-full h-7 text-xs text-muted-foreground hover:text-foreground justify-start px-0" class="w-full h-7 text-xs text-muted-foreground hover:text-foreground justify-start px-0"
@@ -632,11 +629,10 @@ watch(currentConfig, () => {
</Button> </Button>
</div> </div>
<!-- Device Settings Section - Admin only --> <!-- Device Settings Section -->
<template v-if="props.isAdmin"> <Separator />
<Separator />
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5> <h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5>
<Button <Button
@@ -784,8 +780,7 @@ watch(currentConfig, () => {
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" /> <Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span> <span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button> </Button>
</div> </div>
</template>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@@ -4,7 +4,6 @@ import { authApi, systemApi } from '@/api'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const user = ref<string | null>(null) const user = ref<string | null>(null)
const isAdmin = ref(false)
const isAuthenticated = ref(false) const isAuthenticated = ref(false)
const initialized = ref(false) const initialized = ref(false)
const needsSetup = ref(false) const needsSetup = ref(false)
@@ -30,12 +29,10 @@ export const useAuthStore = defineStore('auth', () => {
const result = await authApi.check() const result = await authApi.check()
isAuthenticated.value = result.authenticated isAuthenticated.value = result.authenticated
user.value = result.user || null user.value = result.user || null
isAdmin.value = result.is_admin ?? false
return result return result
} catch (e) { } catch (e) {
isAuthenticated.value = false isAuthenticated.value = false
user.value = null user.value = null
isAdmin.value = false
error.value = e instanceof Error ? e.message : 'Not authenticated' error.value = e instanceof Error ? e.message : 'Not authenticated'
if (e instanceof Error) { if (e instanceof Error) {
throw e throw e
@@ -53,13 +50,6 @@ export const useAuthStore = defineStore('auth', () => {
if (result.success) { if (result.success) {
isAuthenticated.value = true isAuthenticated.value = true
user.value = username user.value = username
// After login, fetch admin status
try {
const authResult = await authApi.check()
isAdmin.value = authResult.is_admin ?? false
} catch {
isAdmin.value = false
}
return true return true
} else { } else {
error.value = result.message || 'Login failed' error.value = result.message || 'Login failed'
@@ -79,7 +69,6 @@ export const useAuthStore = defineStore('auth', () => {
} finally { } finally {
isAuthenticated.value = false isAuthenticated.value = false
user.value = null user.value = null
isAdmin.value = false
} }
} }
@@ -124,7 +113,6 @@ export const useAuthStore = defineStore('auth', () => {
return { return {
user, user,
isAdmin,
isAuthenticated, isAuthenticated,
initialized, initialized,
needsSetup, needsSetup,

View File

@@ -1923,9 +1923,9 @@ onUnmounted(() => {
:details="hidDetails" :details="hidDetails"
/> />
<!-- MSD Status - Admin only, hidden when CH9329 backend (no USB gadget support) --> <!-- MSD Status - Hidden when CH9329 backend (no USB gadget support) -->
<StatusCard <StatusCard
v-if="authStore.isAdmin && systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'" v-if="systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'"
:title="t('statusCard.msd')" :title="t('statusCard.msd')"
type="msd" type="msd"
:status="msdStatus" :status="msdStatus"
@@ -1988,7 +1988,6 @@ onUnmounted(() => {
:mouse-mode="mouseMode" :mouse-mode="mouseMode"
:video-mode="videoMode" :video-mode="videoMode"
:ttyd-running="ttydStatus?.running" :ttyd-running="ttydStatus?.running"
:is-admin="authStore.isAdmin"
@toggle-fullscreen="toggleFullscreen" @toggle-fullscreen="toggleFullscreen"
@toggle-stats="statsSheetOpen = true" @toggle-stats="statsSheetOpen = true"
@toggle-virtual-keyboard="handleToggleVirtualKeyboard" @toggle-virtual-keyboard="handleToggleVirtualKeyboard"