From 5483edb74ce2ab33320049346340b1cfb82280d7 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Fri, 17 Apr 2026 00:23:10 +0800 Subject: [PATCH] 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. --- app/composables/useAuthSession.ts | 82 +++++++ app/layouts/default.vue | 7 +- app/layouts/not-login.vue | 2 +- app/middleware/auth.global.ts | 40 ++++ app/pages/index/index.vue | 43 +++- app/pages/login/index.vue | 15 +- app/pages/register/index.vue | 11 +- app/plugins/auth-session.server.ts | 28 +++ app/utils/auth-routes.ts | 54 +++++ ...4-16-auth-access-control-implementation-plan.md | 239 +++++++++++++++++++++ .../specs/2026-04-16-auth-access-control-design.md | 198 +++++++++++++++++ server/middleware/10.auth-guard.ts | 26 +++ server/utils/auth-api-routes.ts | 23 ++ 13 files changed, 756 insertions(+), 12 deletions(-) create mode 100644 app/composables/useAuthSession.ts create mode 100644 app/middleware/auth.global.ts create mode 100644 app/plugins/auth-session.server.ts create mode 100644 app/utils/auth-routes.ts create mode 100644 docs/superpowers/plans/2026-04-16-auth-access-control-implementation-plan.md create mode 100644 docs/superpowers/specs/2026-04-16-auth-access-control-design.md create mode 100644 server/middleware/10.auth-guard.ts create mode 100644 server/utils/auth-api-routes.ts diff --git a/app/composables/useAuthSession.ts b/app/composables/useAuthSession.ts new file mode 100644 index 0000000..1c10ea6 --- /dev/null +++ b/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(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>("/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, + }; +} diff --git a/app/layouts/default.vue b/app/layouts/default.vue index b58d962..7a3e681 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -11,8 +11,9 @@ -
- +
+ +
@@ -67,7 +68,7 @@ const menuItems = [ }, { label: "首页页面", - to: "/index", + to: "/", }, ], }, diff --git a/app/layouts/not-login.vue b/app/layouts/not-login.vue index d41cb1d..d6ee668 100644 --- a/app/layouts/not-login.vue +++ b/app/layouts/not-login.vue @@ -64,7 +64,7 @@ const menuItems = [ }, { label: "首页页面", - to: "/index", + to: "/", }, ], }, diff --git a/app/middleware/auth.global.ts b/app/middleware/auth.global.ts new file mode 100644 index 0000000..e680556 --- /dev/null +++ b/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); + } +}); diff --git a/app/pages/index/index.vue b/app/pages/index/index.vue index 3d08253..2a15808 100644 --- a/app/pages/index/index.vue +++ b/app/pages/index/index.vue @@ -1,11 +1,44 @@ \ No newline at end of file diff --git a/app/pages/login/index.vue b/app/pages/login/index.vue index c5c5caf..f619054 100644 --- a/app/pages/login/index.vue +++ b/app/pages/login/index.vue @@ -1,6 +1,8 @@