Browse Source

feat: 更新用户信息显示逻辑,支持昵称或用户名;增强 OAuth 登录流程的错误处理与日志记录;优化 API 路径保护逻辑,添加前端页面访问控制

shadcn-as
npmrun 3 weeks ago
parent
commit
9143e7d00a
  1. 2
      app/pages/admin.vue
  2. 15
      app/pages/auth/login.vue
  3. 2
      nuxt.config.ts
  4. BIN
      packages/drizzle-pkg/db.sqlite
  5. 2
      server/api/auth/profile.put.ts
  6. 11
      server/api/cards.get.ts
  7. 2
      server/api/config/global.put.ts
  8. 5
      server/api/pic/random.get.ts
  9. 44
      server/middleware/02.auth-guard.ts
  10. 1
      server/service/captcha/challenge.test.ts
  11. 1
      server/service/captcha/store.test.ts
  12. 6
      server/service/config/index.ts
  13. 9
      server/service/config/normalization.ts
  14. 28
      server/service/oauth/oauth-manager.ts
  15. 49
      server/utils/auth-api-routes.ts

2
app/pages/admin.vue

@ -59,7 +59,7 @@ const logout = async () => {
{{ user.username?.charAt(0).toUpperCase() }} {{ user.username?.charAt(0).toUpperCase() }}
</div> </div>
<div class="user-info"> <div class="user-info">
<span class="user-name">{{ user.username }}</span> <span class="user-name">{{ user.nickname || user.username }}</span>
<span class="user-role">{{ user.role === "user" ? "普通用户" : "管理员" }}</span> <span class="user-role">{{ user.role === "user" ? "普通用户" : "管理员" }}</span>
</div> </div>
<button class="logout-btn" @click.stop.prevent="logout" title="退出登录"> <button class="logout-btn" @click.stop.prevent="logout" title="退出登录">

15
app/pages/auth/login.vue

@ -7,6 +7,21 @@ const route = useRoute()
const { $toast } = useNuxtApp() const { $toast } = useNuxtApp()
const redirect = computed(() => route.query.redirect as string || '/') const redirect = computed(() => route.query.redirect as string || '/')
onMounted(() => {
const url = new URL(window.location.href)
if (url.searchParams.get('oauth_success') === '1') {
refresh(true)
router.push('/')
}
if (url.searchParams.get('oauth_error')) {
const error = url.searchParams.get('oauth_error')
console.error('OAuth error:', error)
url.searchParams.delete('oauth_error')
window.history.replaceState({}, document.title, url.href);
$toast.error(`OAuth 登录失败: ${error}`)
}
})
const loginForm = reactive({ const loginForm = reactive({
username: '', username: '',
password: '', password: '',

2
nuxt.config.ts

@ -29,7 +29,7 @@ export default defineNuxtConfig({
compilerOptions: { compilerOptions: {
resolvePackageJsonExports: true, resolvePackageJsonExports: true,
resolvePackageJsonImports: true, resolvePackageJsonImports: true,
baseUrl: './', ignoreDeprecations: "6.0",
paths: { paths: {
'drizzle-pkg': ['./packages/drizzle-pkg/lib'], 'drizzle-pkg': ['./packages/drizzle-pkg/lib'],
'logger': ['./packages/logger/lib'] 'logger': ['./packages/logger/lib']

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

2
server/api/auth/profile.put.ts

@ -69,7 +69,7 @@ export default defineWrappedResponseHandler(async (event) => {
const [row] = await dbGlobal const [row] = await dbGlobal
.select({ .select({
id: users.id, id: users.id,
// username: users.username, username: users.username,
email: users.email, email: users.email,
role: users.role, role: users.role,
nickname: users.nickname, nickname: users.nickname,

11
server/api/cards.get.ts

@ -93,18 +93,20 @@ const typeCycle: CardType[] = [
] ]
function genItem(globalIdx: number): CardData { function genItem(globalIdx: number): CardData {
const type = typeCycle[globalIdx % typeCycle.length] const typeCycleLength = typeCycle.length
const type = typeCycle[globalIdx % typeCycleLength] ?? 'text'
const idx = globalIdx % 12 const idx = globalIdx % 12
const base: Pick<CardData, 'id' | 'type' | 'title' | 'aspectRatio'> = { const base: Pick<CardData, 'id' | 'type' | 'title' | 'aspectRatio'> = {
id: globalIdx + 1, id: globalIdx + 1,
type, type,
title: titles[idx], title: titles[idx] ?? '默认标题',
aspectRatio: 0.6 + Math.random() * 0.7, aspectRatio: 0.6 + Math.random() * 0.7,
} }
switch (type) { switch (type) {
case 'text': { case 'text': {
const tc = textContents[globalIdx % textContents.length] const tc = textContents[globalIdx % textContents.length]
if (!tc) return { ...base, title: '默认文本', description: undefined, aspectRatio: 0.6 + Math.random() * 0.5 }
return { ...base, title: tc.title, description: tc.description, aspectRatio: 0.6 + Math.random() * 0.5 } return { ...base, title: tc.title, description: tc.description, aspectRatio: 0.6 + Math.random() * 0.5 }
} }
case 'image': case 'image':
@ -113,8 +115,9 @@ function genItem(globalIdx: number): CardData {
return { ...base, image: images[idx], description: descriptions[idx], aspectRatio: 0.6 + Math.random() * 0.7 } return { ...base, image: images[idx], description: descriptions[idx], aspectRatio: 0.6 + Math.random() * 0.7 }
case 'portfolio': { case 'portfolio': {
const set = portfolioSets[globalIdx % portfolioSets.length] const set = portfolioSets[globalIdx % portfolioSets.length]
if (!set) return { ...base, images: [], description: undefined, aspectRatio: 0.85 }
const ar = 0.85 + Math.random() * 0.15 const ar = 0.85 + Math.random() * 0.15
return { ...base, images: set, description: descriptions[idx], aspectRatio: ar } return { ...base, images: set as string[], description: descriptions[idx], aspectRatio: ar }
} }
case 'project': case 'project':
return { return {
@ -124,6 +127,8 @@ function genItem(globalIdx: number): CardData {
tags: projectTags[globalIdx % projectTags.length], tags: projectTags[globalIdx % projectTags.length],
aspectRatio: 0.65 + Math.random() * 0.45, aspectRatio: 0.65 + Math.random() * 0.45,
} }
default:
return { ...base, title: '默认卡片', description: undefined, aspectRatio: 0.6 }
} }
} }

2
server/api/config/global.put.ts

@ -32,7 +32,7 @@ export default defineWrappedResponseHandler(async (event) => {
await delCache("config:global:public"); await delCache("config:global:public");
await delCache("config:global:admin"); await delCache("config:global:admin");
const value = await getConfigGlobal(event, key); const value = await getConfigGlobal(key);
return R.success({ return R.success({
key, key,
value: toSafeResponseValue(key, value), value: toSafeResponseValue(key, value),

5
server/api/pic/random.get.ts

@ -7,7 +7,7 @@ const handler = eventHandler(async (event: H3Event) => {
if (Reflect.has(query, "auto")) { if (Reflect.has(query, "auto")) {
try { try {
return await $fetch("https://api.miaomc.cn/image/get", { method: "get", mode: "cors" }) return await $fetch<string>("https://api.miaomc.cn/image/get", { method: "get", mode: "cors" })
} catch (error) {} } catch (error) {}
try { try {
return await sendRedirect( return await sendRedirect(
@ -52,5 +52,6 @@ const handler = eventHandler(async (event: H3Event) => {
</html>`; </html>`;
}); });
export type ReturnData = Awaited<ReturnType<typeof handler>>; type ReturnData = Awaited<ReturnType<typeof handler>>;
export type { ReturnData };
export default handler; export default handler;

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

@ -1,27 +1,45 @@
import { UNAUTHORIZED_MESSAGE } from "#server/constants/auth"; import { UNAUTHORIZED_MESSAGE } from "#server/constants/auth";
import { isAllowlistedApiPath } from "#server/utils/auth-api-routes"; import { isAllowlistedApiPath, isFrontendPageAllowed } from "#server/utils/auth-api-routes";
import { getCurrentUser } from "#server/utils/context"; import { getCurrentUser } from "#server/utils/context";
export default eventHandler(async (event) => { export default eventHandler(async (event) => {
const path = event.path; const path = event.path;
if (!path.startsWith("/api/")) {
return; // ====================== API 路径保护 ======================
if (path.startsWith("/api/")) {
if (path.startsWith("/api/_nuxt_icon")) {
return;
}
if (isAllowlistedApiPath(path, event.method)) {
return;
}
const user = await getCurrentUser(event);
if (user) {
return;
}
throw createError({
statusCode: 401,
statusMessage: UNAUTHORIZED_MESSAGE,
});
} }
// if (path.startsWith("/api/_nuxt_icon")) {
// return;
// }
if (isAllowlistedApiPath(path, event.method)) { // ====================== 前端页面访问限制 ======================
// 非白名单的前端页面需要登录才能直接访问
if (isFrontendPageAllowed(path)) {
return; return;
} }
const user = await getCurrentUser(event); const user = await getCurrentUser(event);
if (user) { if (!user) {
return; // 未登录且页面不在白名单,重定向到登录页
return sendRedirect(event, "/auth/login", 302);
} }
throw createError({ // 已登录用户访问登录/注册页面,重定向到首页
statusCode: 401, if (path.startsWith("/auth/login") || path.startsWith("/auth/register")) {
statusMessage: UNAUTHORIZED_MESSAGE, return sendRedirect(event, "/", 302);
}); }
}); });

1
server/service/captcha/challenge.test.ts

@ -1,3 +1,4 @@
// @ts-nocheck
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createCaptchaChallenge } from "./challenge"; import { createCaptchaChallenge } from "./challenge";

1
server/service/captcha/store.test.ts

@ -1,3 +1,4 @@
// @ts-nocheck
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { captchaConsume, captchaCreate } from "./store"; import { captchaConsume, captchaCreate } from "./store";

6
server/service/config/index.ts

@ -8,11 +8,13 @@ import {
getConfigDefinition, getConfigDefinition,
getConfigScope, getConfigScope,
isKnownConfigKey, isKnownConfigKey,
KnownConfigKey,
KnownConfigValue,
serializeConfigValue, serializeConfigValue,
validateConfigValue, validateConfigValue,
} from "./registry"; } from "./registry";
import type {
KnownConfigKey,
KnownConfigValue,
} from "./registry";
import { normalizeGlobalConfigValue } from "./normalization"; import { normalizeGlobalConfigValue } from "./normalization";
export class ConfigKeyNotFoundError extends Error { export class ConfigKeyNotFoundError extends Error {

9
server/service/config/normalization.ts

@ -1,17 +1,18 @@
import { KnownConfigKey } from "./registry"; import type { KnownConfigKey } from "./registry";
const TRIMMABLE_GLOBAL_CONFIG_KEYS = new Set<KnownConfigKey>([ const TRIMMABLE_GLOBAL_CONFIG_KEYS = [
"siteName",
"commentMailFromEmail", "commentMailFromEmail",
"commentSmtpHost", "commentSmtpHost",
"commentSmtpUser", "commentSmtpUser",
"commentSmtpPass", "commentSmtpPass",
]); ] as const;
export function normalizeGlobalConfigValue<K extends KnownConfigKey>(key: K, value: unknown): unknown { export function normalizeGlobalConfigValue<K extends KnownConfigKey>(key: K, value: unknown): unknown {
if (typeof value !== "string") { if (typeof value !== "string") {
return value; return value;
} }
if (key === "siteName" || TRIMMABLE_GLOBAL_CONFIG_KEYS.has(key)) { if ((TRIMMABLE_GLOBAL_CONFIG_KEYS as readonly string[]).includes(key)) {
return value.trim(); return value.trim();
} }
return value; return value;

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

@ -1,3 +1,4 @@
import log4js from "logger";
import { dbGlobal } from 'drizzle-pkg/lib/db'; import { dbGlobal } from 'drizzle-pkg/lib/db';
import { oauthAccounts } from 'drizzle-pkg/lib/schema/auth'; import { oauthAccounts } from 'drizzle-pkg/lib/schema/auth';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
@ -6,6 +7,8 @@ import { OAuthError, OAuthErrorCodes } from './oauth-error';
import { registerUser, createSession } from '#server/service/auth'; import { registerUser, createSession } from '#server/service/auth';
import type { MinimalUser } from '#server/service/auth'; import type { MinimalUser } from '#server/service/auth';
const logger = log4js.getLogger("OAUTH");
export type OAuthBinding = { export type OAuthBinding = {
id: number; id: number;
provider: string; provider: string;
@ -77,6 +80,7 @@ export class OAuthManager {
getAuthorizationUrl(providerName: string, userId?: number): string { getAuthorizationUrl(providerName: string, userId?: number): string {
const provider = providers[providerName]; const provider = providers[providerName];
if (!provider) { if (!provider) {
logger.warn(`[getAuthorizationUrl] Provider not found: ${providerName}`);
throw new OAuthError( throw new OAuthError(
OAuthErrorCodes.PROVIDER_NOT_FOUND, OAuthErrorCodes.PROVIDER_NOT_FOUND,
`Provider ${providerName} not found` `Provider ${providerName} not found`
@ -89,6 +93,8 @@ export class OAuthManager {
const { state } = this.stateStore.generate(providerName, redirectUri, userId); const { state } = this.stateStore.generate(providerName, redirectUri, userId);
logger.info(`[getAuthorizationUrl] Generating auth URL for provider: ${providerName}, userId: ${userId ?? 'anonymous'}, redirectUri: ${redirectUri}`);
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: provider.clientId, client_id: provider.clientId,
redirect_uri: provider.redirectUri, redirect_uri: provider.redirectUri,
@ -104,8 +110,11 @@ export class OAuthManager {
code: string, code: string,
state: string state: string
): Promise<OAuthCallbackResult> { ): Promise<OAuthCallbackResult> {
logger.info(`[handleCallback] Provider: ${providerName}`);
const provider = providers[providerName]; const provider = providers[providerName];
if (!provider) { if (!provider) {
logger.warn(`[handleCallback] Provider not found: ${providerName}`);
throw new OAuthError( throw new OAuthError(
OAuthErrorCodes.PROVIDER_NOT_FOUND, OAuthErrorCodes.PROVIDER_NOT_FOUND,
`Provider ${providerName} not found` `Provider ${providerName} not found`
@ -114,6 +123,7 @@ export class OAuthManager {
const oauthState = this.stateStore.consume(state); const oauthState = this.stateStore.consume(state);
if (!oauthState) { if (!oauthState) {
logger.warn(`[handleCallback] Invalid or expired state`);
throw new OAuthError( throw new OAuthError(
OAuthErrorCodes.STATE_INVALID, OAuthErrorCodes.STATE_INVALID,
'Invalid or expired state' 'Invalid or expired state'
@ -121,6 +131,7 @@ export class OAuthManager {
} }
if (oauthState.providerName !== providerName) { if (oauthState.providerName !== providerName) {
logger.warn(`[handleCallback] State provider mismatch: expected ${oauthState.providerName}, got ${providerName}`);
throw new OAuthError( throw new OAuthError(
OAuthErrorCodes.STATE_INVALID, OAuthErrorCodes.STATE_INVALID,
'State provider mismatch' 'State provider mismatch'
@ -129,6 +140,7 @@ export class OAuthManager {
const tokenResponse = await this.exchangeToken(provider, code); const tokenResponse = await this.exchangeToken(provider, code);
if (!tokenResponse.access_token) { if (!tokenResponse.access_token) {
logger.error(`[handleCallback] Token exchange failed for provider: ${providerName}`);
throw new OAuthError( throw new OAuthError(
OAuthErrorCodes.TOKEN_EXCHANGE_FAILED, OAuthErrorCodes.TOKEN_EXCHANGE_FAILED,
'Failed to exchange token' 'Failed to exchange token'
@ -137,16 +149,20 @@ export class OAuthManager {
const userInfo = await this.getUserInfo(provider, tokenResponse.access_token); const userInfo = await this.getUserInfo(provider, tokenResponse.access_token);
if (!userInfo) { if (!userInfo) {
logger.error(`[handleCallback] Failed to get user info from provider: ${providerName}`);
throw new OAuthError( throw new OAuthError(
OAuthErrorCodes.USER_INFO_FAILED, OAuthErrorCodes.USER_INFO_FAILED,
'Failed to get user info' 'Failed to get user info'
); );
} }
logger.info(`[handleCallback] User info retrieved: provider=${providerName}, userId=${userInfo.providerUserId}, username=${userInfo.username}`);
const existingAccount = await this.findOAuthAccount(providerName, userInfo.providerUserId); const existingAccount = await this.findOAuthAccount(providerName, userInfo.providerUserId);
if (oauthState.isBinding) { if (oauthState.isBinding) {
if (existingAccount && existingAccount.userId !== oauthState.userId) { if (existingAccount && existingAccount.userId !== oauthState.userId) {
logger.warn(`[handleCallback] OAuth account already bound to another user: provider=${providerName}, providerUserId=${userInfo.providerUserId}`);
throw new OAuthError( throw new OAuthError(
OAuthErrorCodes.ALREADY_BIND, OAuthErrorCodes.ALREADY_BIND,
'This OAuth account is already bound to another user' 'This OAuth account is already bound to another user'
@ -162,6 +178,8 @@ export class OAuthManager {
avatar: userInfo.avatar, avatar: userInfo.avatar,
}); });
logger.info(`[handleCallback] OAuth account bound successfully: userId=${oauthState.userId}, provider=${providerName}`);
return { return {
success: true, success: true,
isNewUser: false, isNewUser: false,
@ -171,6 +189,7 @@ export class OAuthManager {
if (existingAccount) { if (existingAccount) {
const { sessionId, expiresAt } = await createSession(existingAccount.userId); const { sessionId, expiresAt } = await createSession(existingAccount.userId);
logger.info(`[handleCallback] Existing user logged in: userId=${existingAccount.userId}, provider=${providerName}`);
return { return {
success: true, success: true,
isNewUser: false, isNewUser: false,
@ -196,6 +215,8 @@ export class OAuthManager {
const { sessionId, expiresAt } = await createSession(newUser.id); const { sessionId, expiresAt } = await createSession(newUser.id);
logger.info(`[handleCallback] New user registered and logged in: userId=${newUser.id}, provider=${providerName}`);
return { return {
success: true, success: true,
isNewUser: true, isNewUser: true,
@ -209,11 +230,13 @@ export class OAuthManager {
async unbindAccount(userId: number, providerName: string): Promise<void> { async unbindAccount(userId: number, providerName: string): Promise<void> {
const deleted = await this.deleteOAuthAccount(userId, providerName); const deleted = await this.deleteOAuthAccount(userId, providerName);
if (!deleted) { if (!deleted) {
logger.warn(`[unbindAccount] OAuth account not found: userId=${userId}, provider=${providerName}`);
throw new OAuthError( throw new OAuthError(
OAuthErrorCodes.BINDING_USER_MISMATCH, OAuthErrorCodes.BINDING_USER_MISMATCH,
'OAuth account not found' 'OAuth account not found'
); );
} }
logger.info(`[unbindAccount] OAuth account unbound: userId=${userId}, provider=${providerName}`);
} }
async getUserBindings(userId: number): Promise<OAuthBinding[]> { async getUserBindings(userId: number): Promise<OAuthBinding[]> {
@ -224,7 +247,7 @@ export class OAuthManager {
username: account.username, username: account.username,
email: account.email, email: account.email,
avatar: account.avatar, avatar: account.avatar,
boundAt: account.createdAt, boundAt: account.createdAt ?? new Date(),
})); }));
} }
@ -325,7 +348,8 @@ export class OAuthManager {
eq(oauthAccounts.userId, userId), eq(oauthAccounts.userId, userId),
eq(oauthAccounts.provider, provider) eq(oauthAccounts.provider, provider)
)); ));
return (result.rowCount ?? 0) > 0; const info = result as unknown as { changes?: number; rowsAffected?: number };
return (info.changes ?? info.rowsAffected ?? 0) > 0;
} }
} }

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

@ -10,8 +10,11 @@ const API_ALLOWLIST: RouteRule[] = [
/** 访客可读:无 Cookie 时不查库,用于客户端与 SSR 会话对齐 */ /** 访客可读:无 Cookie 时不查库,用于客户端与 SSR 会话对齐 */
{ path: "/api/auth/session", methods: ["GET"] }, { path: "/api/auth/session", methods: ["GET"] },
{ path: "/api/config/global", methods: ["GET"] }, { path: "/api/config/global", methods: ["GET"] },
{ path: "/api/auth/oauth/github/callback", methods: ["GET"] }, { path: "/api/auth/oauth/:provider/callback", methods: ["GET"] },
{ path: "/api/auth/oauth/github/authorize", methods: ["GET"] }, { path: "/api/auth/oauth/:provider/authorize", methods: ["GET"] },
/** 卡片相关:ID 在路径参数中 */
{ path: "/api/cards/:id", methods: ["GET"] },
{ path: "/api/pic/random", methods: ["GET"] },
]; ];
/** 公开 API 以只读为主,需配合服务端校验与限流 */ /** 公开 API 以只读为主,需配合服务端校验与限流 */
@ -26,13 +29,53 @@ export function isPublicApiPath(path: string, method?: string) {
return false; 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 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) { export function isAllowlistedApiPath(path: string, method?: string) {
if (isPublicApiPath(path, method)) { if (isPublicApiPath(path, method)) {
return true; return true;
} }
const requestMethod = method?.toUpperCase() ?? "GET"; const requestMethod = method?.toUpperCase() ?? "GET";
// 移除 query string
const cleanPath = path.split("?")[0];
return API_ALLOWLIST.some((rule) => { return API_ALLOWLIST.some((rule) => {
if (rule.path !== path.split("?")[0]) { const regex = pathToRegexp(rule.path);
if (!regex.test(cleanPath!)) {
return false; return false;
} }
if (!rule.methods || rule.methods.length === 0) { if (!rule.methods || rule.methods.length === 0) {

Loading…
Cancel
Save