Browse Source

feat: 添加公共配置和路由白名单,优化 OAuth 登录重定向逻辑;更新环境变量以支持管理员凭据

shadcn-as
npmrun 2 weeks ago
parent
commit
1a61b93eb0
  1. 2
      .env.example
  2. 3
      app/middleware/auth.global.ts
  3. 2
      app/pages/admin.vue
  4. 5
      app/pages/admin/users/index.vue
  5. 7
      app/pages/photo/index.vue
  6. 17
      app/utils/auth-routes.ts
  7. 21
      bun.lock
  8. 22
      nuxt.config.ts
  9. 1
      package.json
  10. 23
      packages/common/config/index.ts
  11. 4
      packages/common/package.json
  12. 3
      packages/common/tsconfig.json
  13. 24
      packages/common/utils/path.ts
  14. BIN
      packages/drizzle-pkg/db.sqlite
  15. 7
      server/api/auth/oauth/[provider]/callback.get.ts
  16. 6
      server/middleware/02.auth-guard.ts
  17. 49
      server/service/oauth/oauth-manager.ts
  18. 48
      server/utils/auth-api-routes.ts

2
.env.example

@ -4,6 +4,8 @@ UPLOAD_SUBDIR=upload
NITRO_PORT=3399
SCHEDULER_MAX_CONCURRENCY=5
SCHEDULER_LOG_RETENTION_DAYS=30
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=123456qaz
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
APP_URL=http://localhost:3399

3
app/middleware/auth.global.ts

@ -5,6 +5,7 @@ import {
normalizeSafeRedirect,
} from "@/utils/auth-routes";
import { useAuthSession } from "../composables/useAuthSession";
import { FRONTEND_LOGIN_PATH } from "common/config"
export default defineNuxtRouteMiddleware(async (to) => {
if(to.path.startsWith("/__nuxt_error")) {
@ -27,7 +28,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
if (!isLoggedIn && !isPublicRoute(currentPath)) {
return navigateTo({
path: "/auth",
path: FRONTEND_LOGIN_PATH,
query: { redirect: currentFullPath },
});
}

2
app/pages/admin.vue

@ -22,8 +22,6 @@ const adminNav: NavItem[] = [
icon: 'lucide:settings',
children: [
{ label: '用户管理', to: '/admin/users' },
{ label: '角色管理', to: '/admin/roles' },
{ label: '操作日志', to: '/admin/logs' },
]
},
]

5
app/pages/admin/users/index.vue

@ -0,0 +1,5 @@
<template>
<div>
dsada
</div>
</template>

7
app/pages/photo/index.vue

@ -10,7 +10,6 @@ interface CardItem {
aspectRatio: number
}
const { capture, play } = useFlipAnimation()
const masonryEl = ref<HTMLElement | null>(null)
const allItems = ref<CardItem[]>([])
@ -80,14 +79,8 @@ function updateColumns() {
else n = 5
if (n !== columnCount.value) {
if (masonryEl.value) capture(masonryEl.value, '.card-reveal')
columnCount.value = n
distributeAll()
nextTick(() => {
if (masonryEl.value) {
play(masonryEl.value, '.card-reveal', { duration: 420 })
}
})
}
}

17
app/utils/auth-routes.ts

@ -1,17 +1,10 @@
const PUBLIC_ROUTE_EXACT = new Set(["/", "/auth/login", "/auth/register"]);
const GUEST_ONLY_ROUTE_EXACT = new Set(["/auth/login", "/auth/register"]);
import { FRONTEND_PAGE_ALLOWLIST, FRONTEND_PAGE_GUEST_ONLY } from "common/config"
import { normalizePath } from "common/utils/path"
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)) {
@ -21,11 +14,11 @@ function matchesExactOrPrefix(path: string, exact: Set<string>, prefixes: string
}
export function isPublicRoute(path: string) {
return matchesExactOrPrefix(path, PUBLIC_ROUTE_EXACT, PUBLIC_ROUTE_PREFIXES);
return matchesExactOrPrefix(path, FRONTEND_PAGE_ALLOWLIST, PUBLIC_ROUTE_PREFIXES);
}
export function isGuestOnlyRoute(path: string) {
return GUEST_ONLY_ROUTE_EXACT.has(normalizePath(path));
return FRONTEND_PAGE_GUEST_ONLY.has(normalizePath(path));
}
export function normalizeSafeRedirect(

21
bun.lock

@ -9,6 +9,7 @@
"@nuxt/icon": "2.2.2",
"bcryptjs": "3.0.3",
"cache": "workspace:*",
"common": "workspace:*",
"croner": "10.0.1",
"dotenv": "17.4.1",
"drizzle-orm": "0.45.2",
@ -20,7 +21,6 @@
"mime": "4.1.0",
"multer": "2.1.1",
"nuxt": "4.4.5",
"oauth": "workspace:*",
"svg-captcha": "1.4.0",
"tailwindcss": "4.3.0",
"ufo": "1.6.3",
@ -49,22 +49,15 @@
"@types/node": "20.0.0",
},
},
"packages/common": {
"name": "common",
},
"packages/drizzle-pkg": {
"name": "drizzle-pkg",
},
"packages/logger": {
"name": "logger",
},
"packages/oauth": {
"name": "oauth",
"version": "0.1.0",
"dependencies": {
"zod": "4.3.6",
},
"devDependencies": {
"@types/node": "20.0.0",
},
},
"packages/tsconfig": {
"name": "tsconfig",
},
@ -750,6 +743,8 @@
"commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"common": ["common@workspace:packages/common"],
"commondir": ["commondir@1.0.1", "https://registry.npmmirror.com/commondir/-/commondir-1.0.1.tgz", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"compatx": ["compatx@0.2.0", "https://registry.npmmirror.com/compatx/-/compatx-0.2.0.tgz", {}, "sha512-6gLRNt4ygsi5NyMVhceOCFv14CIdDFN7fQjX1U4+47qVE/+kjPoXMK65KWK+dWxmFzMTuKazoQ9sch6pM0p5oA=="],
@ -1206,8 +1201,6 @@
"nypm": ["nypm@0.6.6", "https://registry.npmmirror.com/nypm/-/nypm-0.6.6.tgz", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="],
"oauth": ["oauth@workspace:packages/oauth"],
"obug": ["obug@2.1.1", "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"ofetch": ["ofetch@1.5.1", "https://registry.npmmirror.com/ofetch/-/ofetch-1.5.1.tgz", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
@ -1854,8 +1847,6 @@
"nuxt/vue-router": ["vue-router@5.0.7", "https://registry.npmmirror.com/vue-router/-/vue-router-5.0.7.tgz", { "dependencies": { "@babel/generator": "^8.0.0-rc.4", "@vue-macros/common": "^3.1.1", "@vue/devtools-api": "^8.1.1", "ast-walker-scope": "^0.8.3", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.15", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.8.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.34", "pinia": "^3.0.4", "vue": "^3.5.34" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia"] }, "sha512-dqfk8kvRbCutmCOCj/XLDqDEYxc1wBdAOGLuVy5M93ifYMsBd5fIjfaPN4tQAbxr5IprdBDIox1gr4wYyOx/SA=="],
"oauth/@types/node": ["@types/node@20.0.0", "https://registry.npmmirror.com/@types/node/-/node-20.0.0.tgz", {}, "sha512-cD2uPTDnQQCVpmRefonO98/PPijuOnnEy5oytWJFPY1N9aJCz2wJ5kSGWO+zJoed2cY2JxQh6yBuUq4vIn61hw=="],
"postcss-colormin/postcss": ["postcss@8.5.14", "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"postcss-convert-values/postcss": ["postcss@8.5.14", "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],

22
nuxt.config.ts

@ -18,11 +18,25 @@ export default defineNuxtConfig({
devtools: { enabled: true },
vite: {
optimizeDeps: {
include: [
'vue3-toastify',
]
},
plugins: [
tailwindcss(),
]
},
typescript: {
tsConfig: {
compilerOptions: {
ignoreDeprecations: "6.0",
paths: {
'common': ['./packages/common']
},
}
}
},
nitro: {
typescript: {
tsConfig: {
@ -31,8 +45,10 @@ export default defineNuxtConfig({
resolvePackageJsonImports: true,
ignoreDeprecations: "6.0",
paths: {
'drizzle-pkg': ['./packages/drizzle-pkg/lib'],
'logger': ['./packages/logger/lib']
'drizzle-pkg': ['./packages/drizzle-pkg'],
'cache': ['./packages/cache'],
'common': ['./packages/common'],
'logger': ['./packages/logger']
},
}
},

1
package.json

@ -23,6 +23,7 @@
"@nuxt/icon": "2.2.2",
"bcryptjs": "3.0.3",
"cache": "workspace:*",
"common": "workspace:*",
"croner": "10.0.1",
"dotenv": "17.4.1",
"drizzle-orm": "0.45.2",

23
packages/common/config/index.ts

@ -0,0 +1,23 @@
export type RouteRule = {
path: string;
methods?: string[];
};
export const API_ALLOWLIST: RouteRule[] = [
{ path: "/api/auth/captcha", methods: ["GET"] },
{ path: "/api/auth/login", methods: ["POST"] },
{ path: "/api/auth/register", methods: ["POST"] },
/** 访客可读:无 Cookie 时不查库,用于客户端与 SSR 会话对齐 */
{ path: "/api/auth/session", methods: ["GET"] },
{ path: "/api/config/global", methods: ["GET"] },
{ path: "/api/auth/oauth/:provider/callback", methods: ["GET"] },
{ path: "/api/auth/oauth/:provider/authorize", methods: ["GET"] },
/** 卡片相关:ID 在路径参数中 */
{ path: "/api/cards/:id", methods: ["GET"] },
{ path: "/api/pic/random", methods: ["GET"] },
];
export const FRONTEND_LOGIN_PATH = "/auth/login"
export const FRONTEND_REGISTER_PATH = "/auth/register"
export const FRONTEND_PAGE_ALLOWLIST = new Set(["/", FRONTEND_LOGIN_PATH, FRONTEND_REGISTER_PATH])
export const FRONTEND_PAGE_GUEST_ONLY = new Set([FRONTEND_LOGIN_PATH, FRONTEND_REGISTER_PATH])

4
packages/common/package.json

@ -0,0 +1,4 @@
{
"name": "common",
"sideEffects": false
}

3
packages/common/tsconfig.json

@ -0,0 +1,3 @@
{
"extends": "tsconfig/tsconfig.json"
}

24
packages/common/utils/path.ts

@ -0,0 +1,24 @@
/**
* :
* - `:id`
* - `:id+`
* - `:id?`
*/
export function pathToRegexp(pattern: string): RegExp {
// 只匹配 :param 部分(到下一个 / 或字符串结尾),不包括后续路径段
const escaped = pattern.replace(/:[^/]+(\?)?/g, (match) => {
if (match.endsWith("?")) {
return "([^/]*)";
}
return "([^/]+)";
});
return new RegExp(`^${escaped}$`);
}
export function normalizePath(path: string) {
const trimmed = path.trim();
if (!trimmed) {
return "/";
}
return trimmed.length > 1 ? trimmed.replace(/\/+$/, "") : trimmed;
}

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

7
server/api/auth/oauth/[provider]/callback.get.ts

@ -1,6 +1,7 @@
import { oauthManager } from '#server/service/oauth/oauth-manager';
import { setSessionCookie } from '#server/service/auth/cookie';
import { OAuthError } from '#server/service/oauth/oauth-error';
import { FRONTEND_LOGIN_PATH } from "common/config"
export default defineWrappedResponseHandler(async (event) => {
const providerName = getRouterParam(event, 'provider');
@ -9,7 +10,7 @@ export default defineWrappedResponseHandler(async (event) => {
const { code, state } = query as { code?: string; state?: string };
if (!code || !state) {
return sendRedirect(event, '/auth/login?oauth_error=missing_params');
return sendRedirect(event, `${FRONTEND_LOGIN_PATH}?oauth_error=missing_params`);
}
try {
@ -19,9 +20,9 @@ export default defineWrappedResponseHandler(async (event) => {
setSessionCookie(event, result.sessionId);
}
return sendRedirect(event, '/auth/login?oauth_success=1');
return sendRedirect(event, `${FRONTEND_LOGIN_PATH}??oauth_success=1`);
} catch (error) {
const errorCode = error instanceof OAuthError ? error.code : 'OAUTH_UNKNOWN';
return sendRedirect(event, `/auth/login?oauth_error=${errorCode}`);
return sendRedirect(event, `${FRONTEND_LOGIN_PATH}??oauth_error=${errorCode}`);
}
});

6
server/middleware/02.auth-guard.ts

@ -1,6 +1,8 @@
import { UNAUTHORIZED_MESSAGE } from "#server/constants/auth";
import { isAllowlistedApiPath, isFrontendPageAllowed } from "#server/utils/auth-api-routes";
import { getCurrentUser } from "#server/utils/context";
import { FRONTEND_LOGIN_PATH, FRONTEND_PAGE_GUEST_ONLY } from "common/config"
import { normalizePath } from "common/utils/path";
export default eventHandler(async (event) => {
const path = event.path;
@ -35,11 +37,11 @@ export default eventHandler(async (event) => {
const user = await getCurrentUser(event);
if (!user) {
// 未登录且页面不在白名单,重定向到登录页
return sendRedirect(event, "/auth/login", 302);
return sendRedirect(event, FRONTEND_LOGIN_PATH, 302);
}
// 已登录用户访问登录/注册页面,重定向到首页
if (path.startsWith("/auth/login") || path.startsWith("/auth/register")) {
if (FRONTEND_PAGE_GUEST_ONLY.has(normalizePath(path))) {
return sendRedirect(event, "/", 302);
}
});

49
server/service/oauth/oauth-manager.ts

@ -6,6 +6,7 @@ import { OAuthStateStore, oauthStateStore } from './oauth-state-store';
import { OAuthError, OAuthErrorCodes } from './oauth-error';
import { registerUser, createSession } from '#server/service/auth';
import type { MinimalUser } from '#server/service/auth';
import { FRONTEND_LOGIN_PATH } from "common/config"
const logger = log4js.getLogger("OAUTH");
@ -28,6 +29,14 @@ export type OAuthCallbackResult = {
expiresAt?: Date;
};
export type FieldMapping = {
provider?: string;
providerUserId: string;
username?: string;
email?: string;
avatar?: string;
};
export type OAuthProviderConfig = {
name: string;
icon: string;
@ -38,13 +47,7 @@ export type OAuthProviderConfig = {
userInfoUrl: string;
scopes: string[];
redirectUri: string;
mapUserInfo: (raw: Record<string, unknown>) => {
provider: string;
providerUserId: string;
username?: string;
email?: string;
avatar?: string;
};
userInfoMapping: FieldMapping;
};
const providers: Record<string, OAuthProviderConfig> = {
@ -58,14 +61,11 @@ const providers: Record<string, OAuthProviderConfig> = {
userInfoUrl: 'https://api.github.com/user',
scopes: ['read:user', 'user:email'],
redirectUri: `${process.env.APP_URL}/api/auth/oauth/github/callback`,
mapUserInfo(raw: Record<string, unknown>) {
return {
provider: 'github',
providerUserId: String(raw.id ?? ''),
username: String(raw.login ?? ''),
email: String(raw.email ?? ''),
avatar: String(raw.avatar_url ?? ''),
};
userInfoMapping: {
providerUserId: 'id',
username: 'login',
email: 'email',
avatar: 'avatar_url',
},
},
};
@ -89,7 +89,7 @@ export class OAuthManager {
const redirectUri = userId
? `/profile?bind_success=1`
: `/auth/login?oauth_success=1`;
: `${FRONTEND_LOGIN_PATH}?oauth_success=1`;
const { state } = this.stateStore.generate(providerName, redirectUri, userId);
@ -283,6 +283,21 @@ export class OAuthManager {
}>;
}
private mapUserInfo(raw: Record<string, unknown>, mapping: FieldMapping) {
const getValue = (path: string) => {
const value = (raw as Record<string, unknown>)[path];
return value != null ? String(value) : undefined;
};
return {
provider: mapping.provider || '',
providerUserId: getValue(mapping.providerUserId) || '',
username: mapping.username ? getValue(mapping.username) : undefined,
email: mapping.email ? getValue(mapping.email) : undefined,
avatar: mapping.avatar ? getValue(mapping.avatar) : undefined,
};
}
private async getUserInfo(
provider: OAuthProviderConfig,
accessToken: string
@ -299,7 +314,7 @@ export class OAuthManager {
}
const raw = await response.json() as Record<string, unknown>;
return provider.mapUserInfo(raw);
return this.mapUserInfo(raw, provider.userInfoMapping);
}
private async createOAuthAccount(data: {

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

@ -1,21 +1,5 @@
type RouteRule = {
path: string;
methods?: string[];
};
const API_ALLOWLIST: RouteRule[] = [
{ path: "/api/auth/captcha", methods: ["GET"] },
{ path: "/api/auth/login", methods: ["POST"] },
{ path: "/api/auth/register", methods: ["POST"] },
/** 访客可读:无 Cookie 时不查库,用于客户端与 SSR 会话对齐 */
{ path: "/api/auth/session", methods: ["GET"] },
{ path: "/api/config/global", methods: ["GET"] },
{ path: "/api/auth/oauth/:provider/callback", methods: ["GET"] },
{ path: "/api/auth/oauth/:provider/authorize", methods: ["GET"] },
/** 卡片相关:ID 在路径参数中 */
{ path: "/api/cards/:id", methods: ["GET"] },
{ path: "/api/pic/random", methods: ["GET"] },
];
import { API_ALLOWLIST, FRONTEND_PAGE_ALLOWLIST } from "common/config"
import { pathToRegexp } from "common/utils/path"
/** 公开 API 以只读为主,需配合服务端校验与限流 */
export function isPublicApiPath(path: string, method?: string) {
@ -29,42 +13,18 @@ export function isPublicApiPath(path: string, method?: string) {
return false;
}
/** 前端页面白名单:无需登录即可直接访问 */
const FRONTEND_PAGE_ALLOWLIST: RouteRule[] = [
{ path: "/auth/login" },
{ path: "/auth/register" },
{ path: "/" },
];
/**
* 访
* 访
*/
export function isFrontendPageAllowed(path: string): boolean {
const cleanPath = path.split("?")[0];
return FRONTEND_PAGE_ALLOWLIST.some((rule) => {
const regex = pathToRegexp(rule.path);
return Array.from(FRONTEND_PAGE_ALLOWLIST).some((rule) => {
const regex = pathToRegexp(rule);
return regex.test(cleanPath!);
});
}
/**
* :
* - `:id`
* - `:id+`
* - `:id?`
*/
function pathToRegexp(pattern: string): RegExp {
// 只匹配 :param 部分(到下一个 / 或字符串结尾),不包括后续路径段
const escaped = pattern.replace(/:[^/]+(\?)?/g, (match) => {
if (match.endsWith("?")) {
return "([^/]*)";
}
return "([^/]+)";
});
return new RegExp(`^${escaped}$`);
}
export function isAllowlistedApiPath(path: string, method?: string) {
if (isPublicApiPath(path, method)) {
return true;

Loading…
Cancel
Save