Browse Source

feat: implement authentication access control with session management

Add a unified authentication session composable to manage user state and session handling. Implement global route middleware for access control, ensuring protected routes require authentication. Introduce utility functions for route validation and safe redirect handling. Update login and registration pages to support new authentication flow and redirect logic. Enhance homepage rendering based on user authentication status.
tags/邮箱功能前置
npmrun 6 days ago
parent
commit
5483edb74c
  1. 82
      app/composables/useAuthSession.ts
  2. 7
      app/layouts/default.vue
  3. 2
      app/layouts/not-login.vue
  4. 40
      app/middleware/auth.global.ts
  5. 43
      app/pages/index/index.vue
  6. 15
      app/pages/login/index.vue
  7. 11
      app/pages/register/index.vue
  8. 28
      app/plugins/auth-session.server.ts
  9. 54
      app/utils/auth-routes.ts
  10. 239
      docs/superpowers/plans/2026-04-16-auth-access-control-implementation-plan.md
  11. 198
      docs/superpowers/specs/2026-04-16-auth-access-control-design.md
  12. 26
      server/middleware/10.auth-guard.ts
  13. 23
      server/utils/auth-api-routes.ts

82
app/composables/useAuthSession.ts

@ -0,0 +1,82 @@
import { request, unwrapApiBody, type ApiResponse } from "../utils/http/factory";
export type AuthUser = {
id: number;
username: string;
};
type MeResult = {
user: AuthUser;
};
export type AuthSessionState = {
initialized: boolean;
pending: boolean;
loggedIn: boolean;
user: AuthUser | null;
};
export const AUTH_SESSION_STATE_KEY = "auth:session";
export const DEFAULT_AUTH_SESSION_STATE: AuthSessionState = {
initialized: false,
pending: false,
loggedIn: false,
user: null,
};
function isUnauthorized(error: unknown) {
if (typeof error !== "object" || error === null) {
return false;
}
return "statusCode" in error && (error as { statusCode?: number }).statusCode === 401;
}
export function useAuthSession() {
const state = useState<AuthSessionState>(AUTH_SESSION_STATE_KEY, () => ({
...DEFAULT_AUTH_SESSION_STATE,
}));
const applyUser = (user: AuthUser | null) => {
state.value.user = user;
state.value.loggedIn = Boolean(user);
state.value.initialized = true;
};
const clear = () => {
applyUser(null);
};
const refresh = async (force = false) => {
if (state.value.initialized && !force) {
return state.value.user;
}
if (state.value.pending) {
return state.value.user;
}
state.value.pending = true;
try {
const fetcher = import.meta.server ? useRequestFetch() : request;
const payload = await fetcher<ApiResponse<MeResult>>("/api/auth/me");
const data = unwrapApiBody(payload);
applyUser(data.user);
return data.user;
} catch (error: unknown) {
if (isUnauthorized(error)) {
clear();
return null;
}
throw error;
} finally {
state.value.pending = false;
}
};
return {
initialized: computed(() => state.value.initialized),
loggedIn: computed(() => state.value.loggedIn),
user: computed(() => state.value.user),
pending: computed(() => state.value.pending),
refresh,
clear,
};
}

7
app/layouts/default.vue

@ -11,8 +11,9 @@
<UButton color="neutral" variant="ghost" label="菜单" icon="i-lucide-menu" />
</UDropdownMenu>
</div>
<div>
<UButton color="neutral" variant="ghost" label="登录 / 注册" />
<div class="flex items-center gap-2">
<UButton color="neutral" variant="ghost" to="/login" label="登录" />
<UButton color="neutral" variant="outline" to="/register" label="注册" />
</div>
</UContainer>
</header>
@ -67,7 +68,7 @@ const menuItems = [
},
{
label: "首页页面",
to: "/index",
to: "/",
},
],
},

2
app/layouts/not-login.vue

@ -64,7 +64,7 @@ const menuItems = [
},
{
label: "首页页面",
to: "/index",
to: "/",
},
],
},

40
app/middleware/auth.global.ts

@ -0,0 +1,40 @@
import {
DEFAULT_AUTHENTICATED_LANDING_PATH,
isGuestOnlyRoute,
isPublicRoute,
normalizeSafeRedirect,
} from "../utils/auth-routes";
import { useAuthSession } from "../composables/useAuthSession";
export default defineNuxtRouteMiddleware(async (to) => {
const { initialized, loggedIn, refresh } = useAuthSession();
if (!initialized.value) {
await refresh();
}
const currentPath = to.path;
const currentFullPath = to.fullPath;
const isLoggedIn = loggedIn.value;
if (!isLoggedIn && !isPublicRoute(currentPath)) {
return navigateTo({
path: "/login",
query: { redirect: currentFullPath },
});
}
if (isLoggedIn && isGuestOnlyRoute(currentPath)) {
const redirectCandidate = Array.isArray(to.query.redirect)
? to.query.redirect[0]
: to.query.redirect;
const redirectTarget = normalizeSafeRedirect(
redirectCandidate,
DEFAULT_AUTHENTICATED_LANDING_PATH,
);
if (redirectTarget !== currentFullPath && redirectTarget !== currentPath) {
return navigateTo(redirectTarget);
}
return navigateTo(DEFAULT_AUTHENTICATED_LANDING_PATH);
}
});

43
app/pages/index/index.vue

@ -1,11 +1,44 @@
<script setup lang="ts">
const { data, pending, error, refresh } = await useHttpFetch('/api/hello')
import { request, unwrapApiBody, type ApiResponse } from '../../utils/http/factory'
import { useAuthSession } from '../../composables/useAuthSession'
const userCount = computed(() => data.value?.users?.length ?? 0)
const { loggedIn, user, clear } = useAuthSession()
const logoutLoading = ref(false)
async function logout() {
logoutLoading.value = true
try {
unwrapApiBody(await request<ApiResponse<{ success: boolean }>>('/api/auth/logout', { method: 'POST' }))
clear()
await navigateTo('/login')
} finally {
logoutLoading.value = false
}
}
</script>
<template>
<div>
sad
</div>
<div class="max-w-3xl mx-auto py-10">
<UCard v-if="loggedIn">
<template #header>
<h1 class="text-xl font-semibold">欢迎回来</h1>
</template>
<p class="text-sm text-muted">当前用户{{ user?.username }}</p>
<UButton class="mt-4" color="neutral" :loading="logoutLoading" @click="logout">
退出登录
</UButton>
</UCard>
<UCard v-else>
<template #header>
<h1 class="text-xl font-semibold">欢迎来到本站</h1>
</template>
<p class="text-sm text-muted">登录后可访问更多受保护内容</p>
<div class="mt-4 flex gap-3">
<UButton to="/login">去登录</UButton>
<UButton to="/register" color="neutral" variant="outline">去注册</UButton>
</div>
</UCard>
</div>
</template>

15
app/pages/login/index.vue

@ -1,6 +1,8 @@
<script setup lang="ts">
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import { request, unwrapApiBody } from '../../utils/http/factory'
import { request, unwrapApiBody, type ApiResponse } from '../../utils/http/factory'
import { DEFAULT_AUTHENTICATED_LANDING_PATH, normalizeSafeRedirect } from '../../utils/auth-routes'
import { useAuthSession } from '../../composables/useAuthSession'
definePageMeta({
title: '登录',
@ -29,6 +31,8 @@ const state = reactive<LoginFormState>({
const loading = ref(false)
const resultType = ref<'success' | 'error' | ''>('')
const resultMessage = ref('')
const route = useRoute()
const { refresh } = useAuthSession()
const validate = (formState: LoginFormState): FormError[] => {
const errors: FormError[] = []
@ -54,7 +58,7 @@ const onSubmit = async (_event: FormSubmitEvent<LoginFormState>) => {
loading.value = true
try {
const res = unwrapApiBody(await request<LoginResult>('/api/auth/login', {
const res = unwrapApiBody(await request<ApiResponse<LoginResult>>('/api/auth/login', {
method: 'POST',
body: {
username: state.username,
@ -62,8 +66,15 @@ const onSubmit = async (_event: FormSubmitEvent<LoginFormState>) => {
},
}))
await refresh(true)
resultType.value = 'success'
resultMessage.value = `登录成功,欢迎 ${res.user.username}`
const redirectCandidate = Array.isArray(route.query.redirect)
? route.query.redirect[0]
: route.query.redirect
const redirectTarget = normalizeSafeRedirect(redirectCandidate, DEFAULT_AUTHENTICATED_LANDING_PATH)
await navigateTo(redirectTarget)
} catch (error: unknown) {
const message = typeof error === 'object' && error !== null && 'statusMessage' in error
? String(error.statusMessage)

11
app/pages/register/index.vue

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import { request, unwrapApiBody } from '../../utils/http/factory'
import { normalizeSafeRedirect } from '../../utils/auth-routes'
definePageMeta({
title: '注册',
@ -22,6 +23,7 @@ const state = reactive<RegisterFormState>({
const loading = ref(false)
const resultType = ref<'error' | ''>('')
const resultMessage = ref('')
const route = useRoute()
const validate = (formState: RegisterFormState): FormError[] => {
const errors: FormError[] = []
@ -55,7 +57,14 @@ const onSubmit = async (_event: FormSubmitEvent<RegisterFormState>) => {
},
}))
await navigateTo('/login')
const redirectCandidate = Array.isArray(route.query.redirect)
? route.query.redirect[0]
: route.query.redirect
const redirect = normalizeSafeRedirect(redirectCandidate, '/')
await navigateTo({
path: '/login',
query: { redirect },
})
} catch (error: unknown) {
const message = typeof error === 'object' && error !== null && 'statusMessage' in error
? String(error.statusMessage)

28
app/plugins/auth-session.server.ts

@ -0,0 +1,28 @@
import {
AUTH_SESSION_STATE_KEY,
DEFAULT_AUTH_SESSION_STATE,
type AuthSessionState,
} from "../composables/useAuthSession";
export default defineNuxtPlugin(async () => {
const event = useRequestEvent();
if (!event) {
return;
}
const state = useState<AuthSessionState>(AUTH_SESSION_STATE_KEY, () => ({
...DEFAULT_AUTH_SESSION_STATE,
}));
if (state.value.initialized) {
return;
}
const user = await event.context.auth.getCurrent();
state.value = {
initialized: true,
pending: false,
loggedIn: Boolean(user),
user: user ?? null,
};
});

54
app/utils/auth-routes.ts

@ -0,0 +1,54 @@
const PUBLIC_ROUTE_EXACT = new Set(["/", "/login", "/register"]);
const GUEST_ONLY_ROUTE_EXACT = new Set(["/login", "/register"]);
const PUBLIC_ROUTE_PREFIXES: string[] = [];
export const DEFAULT_AUTHENTICATED_LANDING_PATH = "/";
function normalizePath(path: string) {
const trimmed = path.trim();
if (!trimmed) {
return "/";
}
return trimmed.length > 1 ? trimmed.replace(/\/+$/, "") : trimmed;
}
function matchesExactOrPrefix(path: string, exact: Set<string>, prefixes: string[]) {
const normalized = normalizePath(path);
if (exact.has(normalized)) {
return true;
}
return prefixes.some((prefix) => normalized.startsWith(prefix));
}
export function isPublicRoute(path: string) {
return matchesExactOrPrefix(path, PUBLIC_ROUTE_EXACT, PUBLIC_ROUTE_PREFIXES);
}
export function isGuestOnlyRoute(path: string) {
return GUEST_ONLY_ROUTE_EXACT.has(normalizePath(path));
}
export function normalizeSafeRedirect(
value: unknown,
fallback = DEFAULT_AUTHENTICATED_LANDING_PATH,
) {
if (typeof value !== "string") {
return fallback;
}
const candidate = value.trim();
if (!candidate || !candidate.startsWith("/") || candidate.startsWith("//")) {
return fallback;
}
const lower = candidate.toLowerCase();
if (
lower.startsWith("/http:") ||
lower.startsWith("/https:") ||
lower.startsWith("/javascript:")
) {
return fallback;
}
return candidate;
}

239
docs/superpowers/plans/2026-04-16-auth-access-control-implementation-plan.md

@ -0,0 +1,239 @@
# Auth Access Control Implementation Plan
> **For agentic workers:** Execute tasks in order and keep scope fixed to the approved spec. Use checkbox (`- [ ]`) tracking and verify each task before moving on.
**Goal:** Implement login-first access control for both pages and APIs, with code-based allowlists, safe login redirect handling, and dual-mode homepage rendering support.
**Architecture:** Introduce centralized route rule utilities, a global page guard (`auth.global`), a server API guard middleware (`10.auth-guard`), and a unified auth session composable. Default policy is deny-by-default for pages and `/api/**`, with narrow allowlists.
**Tech Stack:** Nuxt 4/Nitro middleware, existing session-cookie auth service, TypeScript utilities, Bun dev/build workflow.
---
## File Structure Map
- Create: `app/utils/auth-routes.ts`
- Create: `app/composables/useAuthSession.ts`
- Create: `app/middleware/auth.global.ts`
- Create: `server/utils/auth-api-routes.ts`
- Create: `server/middleware/10.auth-guard.ts`
- Modify: `app/pages/login/index.vue` (handle redirect after login success)
- Modify: `app/pages/index/index.vue` (guest/authenticated split rendering)
- Optional small adjustments: shared HTTP wrapper for uniform 401 handling if needed
---
### Task 1: Build shared page-route auth rules and redirect sanitizer
**Files:**
- Create: `app/utils/auth-routes.ts`
- Test: utility behavior via unit-like local checks (or temporary assertions)
- [ ] **Step 1: Define route policy constants**
Include:
- public routes exact set (initial: `/`, `/login`, `/register`)
- guest-only routes exact set (initial: `/login`, `/register`)
- default authenticated landing path
- [ ] **Step 2: Implement safe `redirect` parser**
Implement helper like `normalizeSafeRedirect(input, fallback)`:
- allow only same-site relative paths
- must start with `/`
- reject `//`, protocol-like payloads (`http:`, `https:`, `javascript:`), empty strings
- return fallback when invalid
- [ ] **Step 3: Implement route match helpers**
Provide concise helpers:
- `isPublicRoute(path)`
- `isGuestOnlyRoute(path)`
- matching mode: exact + explicit prefix helper only (no broad regex)
- [ ] **Step 4: Verify sanitizer edge cases**
Validate examples:
- accept: `/dashboard`, `/a?b=1`
- reject: `http://evil.com`, `//evil.com`, `javascript:alert(1)`
---
### Task 2: Add unified auth session composable
**Files:**
- Create: `app/composables/useAuthSession.ts`
- Test: manual behavior from multiple pages without duplicated calls
- [ ] **Step 1: Implement canonical session state**
Expose:
- `loggedIn`
- `user`
- `pending`
- `refresh()`
- `clear()`
Use `/api/auth/me` as source of truth.
- [ ] **Step 2: Handle unauthorized consistently**
When `/api/auth/me` returns 401:
- clear local state
- return `loggedIn=false` instead of throwing noisy UI errors
- [ ] **Step 3: Ensure composable is reusable**
Guarantee all pages/middleware consume this composable, not ad-hoc login checks.
---
### Task 3: Implement global page guard middleware (default deny)
**Files:**
- Create: `app/middleware/auth.global.ts`
- Reuse: `app/utils/auth-routes.ts`, `app/composables/useAuthSession.ts`
- [ ] **Step 1: Enforce default login-required policy**
For every route:
- if route is not public and user not logged in, navigate to `/login?redirect=<fullPath>`
- [ ] **Step 2: Enforce guest-only behavior**
If logged-in user visits guest-only routes:
- resolve safe redirect target
- prioritize validated `redirect` query
- fallback to default authenticated landing path
- [ ] **Step 3: Prevent redirect loops**
Guarantee no self-loop on `/login` and `/register`.
- [ ] **Step 4: Manual route-flow verification**
Check:
- unauth -> protected page -> login redirect with query
- logged-in -> `/login` -> redirected away
- public routes remain reachable when unauthenticated
---
### Task 4: Implement server API auth guard middleware (default deny for `/api/**`)
**Files:**
- Create: `server/utils/auth-api-routes.ts`
- Create: `server/middleware/10.auth-guard.ts`
- [ ] **Step 1: Define API allowlist rules**
Initial allowlist:
- `/api/auth/login`
- `/api/auth/register`
- required public-read APIs (e.g. `GET /api/config/global`)
Use exact + explicit prefix helpers only.
- [ ] **Step 2: Guard `/api/**` requests**
Middleware logic:
- non-API request -> skip
- allowlisted API -> skip
- others -> require valid session from `event.context.auth.getCurrent()`
- [ ] **Step 3: Return unified unauthorized response**
On unauthorized:
- return HTTP 401
- use generic status message
- do not leak auth internals
- [ ] **Step 4: Validate API behavior**
Check with/without cookie:
- protected APIs return 401 when unauthenticated
- allowlisted APIs still work
---
### Task 5: Wire login/home pages to new access-control behavior
**Files:**
- Modify: `app/pages/login/index.vue`
- Modify: `app/pages/index/index.vue`
- [ ] **Step 1: Login page post-success redirect**
After successful login:
- call `useAuthSession().refresh()`
- read `redirect` query and sanitize
- navigate to safe target/fallback
- [ ] **Step 2: Homepage dual-mode rendering**
On `/`:
- unauthenticated: show guest-facing UI
- authenticated: show signed-in UI
- rely on `useAuthSession` state
- [ ] **Step 3: Session-loss UX consistency**
If session expires during interaction:
- state clears
- protected route access re-routes to login
---
### Task 6: Verification matrix and polish
**Files:**
- Test-focused task; no intended new feature files
- [ ] **Step 1: Route verification matrix**
Run through:
- unauth + public route
- unauth + protected route
- auth + guest-only route
- auth + protected route
- [ ] **Step 2: Redirect security matrix**
Try login URL examples:
- `/login?redirect=/safe/path` (accepted)
- `/login?redirect=http://evil.com` (rejected)
- `/login?redirect=//evil.com` (rejected)
- [ ] **Step 3: API verification matrix**
Confirm:
- unauth -> protected `/api/**` => 401
- allowlisted APIs accessible unauthenticated
- expired session behaves as unauthorized
- [ ] **Step 4: Build/lint sanity**
Run:
- `bun run build`
- lint/type checks used by the repo
Fix only issues introduced by this scope.
---
## Delivery Constraints
- Keep allowlists in code (no remote config source for auth policy)
- No RBAC/role granularity in this iteration
- No OAuth/SSO/password reset in this iteration
- Avoid broad route regexes that can accidentally expand exposure
## Done Criteria
All below must be true:
- Page policy is deny-by-default with explicit public routes
- API policy is deny-by-default under `/api/**` with explicit allowlist
- Redirect handling is safe against open-redirect payloads
- Login and homepage behavior matches agreed UX
- Verification matrix passes without known regressions

198
docs/superpowers/specs/2026-04-16-auth-access-control-design.md

@ -0,0 +1,198 @@
# 权限与访问控制设计(Nuxt)
## 背景与目标
当前项目已有基础账号体系与会话能力(登录、注册、获取当前用户),但缺少统一的页面与 API 访问控制策略。
本设计目标:
- 采用**登录优先**:默认受保护,少量白名单放行
- 保持实现简洁、易维护、可扩展
- 避免常见安全问题(越权、开放重定向、误放行、重定向循环)
- 支持“同一路由按登录态展示不同内容”的页面模式
## 需求结论(已确认)
- 白名单配置方式:**代码静态配置**
- 页面策略:**默认拦截,白名单放行**
- 未登录白名单页面:`/`、`/login`、`/register`,并预留认证辅助页扩展
- 页面展示模式:**混合模式**
- 入口页(如 `/`)同路由按登录态切换
- 复杂业务页拆分为受保护路由
- 未登录访问受保护页面:跳转 `/login?redirect=...`,登录后回跳
- API 策略:**默认要求登录**,仅少数白名单 API 放行
## 总体方案(方案 1)
采用“双层守卫”:
1. 前端全局路由中间件(页面守卫)
2. 服务端 API 中间件(接口守卫)
两层共同保证:即使前端绕过,后端仍可阻断未授权调用。
## 架构设计
### 1) 前端会话状态层
新增统一会话组合式函数(例如 `app/composables/useAuthSession.ts`):
- 负责请求 `/api/auth/me`
- 暴露 `loggedIn`、`user`、`pending`、`refresh`、`clear`
- 登录成功后调用 `refresh`
- 登出或 `401` 时调用 `clear`
设计原则:
- 不在多个页面重复写“是否登录”逻辑
- 统一处理会话失效状态
### 2) 页面守卫层(全局)
新增 `app/middleware/auth.global.ts`,策略如下:
- 默认:页面需要登录
- 放行白名单:`/`、`/login`、`/register` 及后续认证辅助页(代码增补)
- 未登录访问受保护页:
- 跳转 `/login?redirect=<当前完整路径>`
- 已登录访问游客页(如登录/注册页):
- 优先跳 `redirect`
- 无合法 `redirect` 则跳默认登录后落地页(初始可设为 `/`
### 3) API 守卫层(服务端)
新增 `server/middleware/10.auth-guard.ts`,策略如下:
- 仅处理 `/api/**`
- 默认要求登录
- API 白名单(初始):
- `/api/auth/login`
- `/api/auth/register`
- 必要公开读取接口(如 `/api/config/global` 的 GET)
- 未登录或无效会话:统一返回 `401`
补充:
- 登录态来源统一走 `event.context.auth.getCurrent()`(沿用现有上下文能力)
- 不在错误响应中暴露内部实现细节
## 关键安全设计
### 1) redirect 安全校验(防开放重定向)
`redirect` 仅接受站内相对路径,校验规则:
- 必须以 `/` 开头
- 禁止 `//` 开头
- 禁止包含协议(如 `http:`、`https:`、`javascript:`)
- 非法值全部降级为默认落地页
### 2) 白名单匹配规则(防误放行)
采用“精确匹配 + 明确前缀匹配”:
- 精确:`/login`
- 前缀:`/auth/forgot-password`(示例)
禁止宽泛正则(如 `^/a` 这类容易误伤或放大权限边界的规则)。
### 3) 未授权语义统一
- API 统一返回 `401`
- 页面统一重定向到登录页
- 前端收到 `401` 后同步清理本地会话状态,避免脏态
### 4) 防重定向循环
- 登录/注册页不再跳自身
- 当 `redirect` 指向游客页本身时,降级到默认落地页
## 代码组织建议
建议新增/调整文件:
- `app/composables/useAuthSession.ts`:会话读取与缓存
- `app/middleware/auth.global.ts`:页面访问控制
- `app/utils/auth-routes.ts`:页面白名单、游客页集合、`redirect` 校验工具
- `server/middleware/10.auth-guard.ts`:API 访问控制
- `server/utils/auth-api-routes.ts`:API 白名单规则
约束:
- 路由规则只在一处定义,避免多处分叉
- 页面与 API 规则分别维护,但命名和模式保持一致
## 数据流与行为流
### 场景 A:未登录访问受保护页面
1. 用户进入受保护路由
2. `auth.global` 判定未登录
3. 跳转 `/login?redirect=<原路由>`
4. 登录成功后读取并校验 `redirect`
5. 合法则回跳,不合法则去默认页
### 场景 B:会话过期后访问受保护 API
1. 请求命中 API 守卫
2. 会话失效,返回 `401`
3. 前端拦截到 `401`,清理登录态
4. 若当前在受保护页面,触发跳转到登录页
### 场景 C:首页双态展示
1. 首页属于白名单可访问
2. 页面内部根据 `loggedIn` 渲染访客态或用户态
3. 刷新会话后自动切换显示
## 测试与验收标准
### 路由访问测试
- 未登录访问受保护页应跳登录并保留 `redirect`
- 已登录访问登录/注册页应跳转到有效 `redirect` 或默认页
- 白名单页面在未登录下可直接访问
### API 鉴权测试
- 未登录访问受保护 API 返回 `401`
- 白名单 API 未登录可正常请求
- 过期会话访问受保护 API 返回 `401`
### 安全测试
- `redirect` 注入(`http://...`、`//...`、`javascript:...`)均被拦截
- 白名单边界:`/login-xxx` 不应命中 `/login`
- 不出现登录页/注册页重定向死循环
### 体验测试
- 首页登录态切换稳定,不出现明显错误闪烁
- 登出后状态同步清空,页面表现与未登录一致
## 渐进实施顺序
1. 增加路由规则常量与 `redirect` 校验工具
2. 落地前端全局中间件(先页面策略)
3. 落地 API 守卫中间件(再后端兜底)
4. 接入统一会话组合式函数,替换分散状态判断
5. 补齐回归测试与手工验收清单
## 非目标(本次不做)
- 角色/权限点(RBAC)细粒度控制
- 多租户权限隔离
- OAuth、SSO 等第三方登录
- 动态远端下发白名单配置
## 风险与应对
- 风险:误将必要接口放入受保护导致前端初始化失败
应对:白名单初始集合最小化后,逐个验证关键页面
- 风险:新增页面忘记考虑权限属性
应对:默认拦截策略天然兜底,新增页面仅在确需公开时加入白名单
- 风险:重定向逻辑复杂导致跳转异常
应对:`redirect` 工具函数集中实现,并覆盖异常值测试
## 设计结论
在当前项目阶段,采用“前端全局路由守卫 + 服务端 API 统一守卫”的登录优先模型,能以最小复杂度建立稳定安全边界,并保留后续扩展(认证辅助页、业务页拆分、角色权限)的空间。

26
server/middleware/10.auth-guard.ts

@ -0,0 +1,26 @@
import { UNAUTHORIZED_MESSAGE } from "#server/constants/auth";
import { isAllowlistedApiPath } from "#server/utils/auth-api-routes";
export default eventHandler(async (event) => {
const path = event.path;
if (!path.startsWith("/api/")) {
return;
}
if (path.startsWith("/api/_nuxt_icon")) {
return;
}
if (isAllowlistedApiPath(path, event.method)) {
return;
}
const user = await event.context.auth.getCurrent();
if (user) {
return;
}
throw createError({
statusCode: 401,
statusMessage: UNAUTHORIZED_MESSAGE,
});
});

23
server/utils/auth-api-routes.ts

@ -0,0 +1,23 @@
type RouteRule = {
path: string;
methods?: string[];
};
const API_ALLOWLIST: RouteRule[] = [
{ path: "/api/auth/login", methods: ["POST"] },
{ path: "/api/auth/register", methods: ["POST"] },
{ path: "/api/config/global", methods: ["GET"] },
];
export function isAllowlistedApiPath(path: string, method?: string) {
const requestMethod = method?.toUpperCase() ?? "GET";
return API_ALLOWLIST.some((rule) => {
if (rule.path !== path) {
return false;
}
if (!rule.methods || rule.methods.length === 0) {
return true;
}
return rule.methods.includes(requestMethod);
});
}
Loading…
Cancel
Save