feat(auth): 实现基于 NextAuth.js 的管理员认证系统

添加 NextAuth.js 依赖并配置认证模块
创建登录页面和管理后台会话保护中间件
更新 README 文档说明管理员认证功能
This commit is contained in:
bo.yu 2025-09-08 16:45:57 +08:00
parent 11dc584a4e
commit 8c2b422a37
11 changed files with 493 additions and 6 deletions

View file

@ -30,6 +30,15 @@ NEXT_PUBLIC_FONT_STATIC_URL=https://your-cdn-domain.com
DOWNLOAD_URL=https://your-download-domain.com
PREFIX=wenfeng

# NextAuth.js 认证配置
AUTH_SECRET=your-super-secret-key-change-this-in-production
NEXTAUTH_URL=http://localhost:3000

# 管理员账号配置
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_EMAIL=admin@windfonts.com

# 环境标识
NODE_ENV=development
NEXT_PUBLIC_ENV=development

View file

@ -11,10 +11,12 @@
- **搜索功能**:快速查找目标字体

### 🛠️ 管理功能
- **管理员认证**:基于 NextAuth.js 的安全认证系统
- **字体上传**:支持 TTF/OTF 格式字体文件上传
- **字体管理**:增删改查字体信息,包括分类、标签、描述等
- **批量操作**:支持批量管理字体文件
- **版本控制**:字体文件版本管理和更新
- **会话管理**:安全的用户会话和登出功能

### 🚀 API 服务
- **CSS API**:动态生成字体 CSS 文件,支持 Google Fonts 兼容格式
@ -43,6 +45,7 @@
- **PostgreSQL**:可靠的关系型数据库
- **Drizzle ORM**:类型安全的 ORM
- **Redux Toolkit**:状态管理
- **NextAuth.js**:现代化认证解决方案

### 开发工具
- **ESLint**:代码质量检查
@ -79,7 +82,19 @@ DATA_BASE_NAME=windfonts
POSTGRES_URL=postgresql://username:password@localhost:5432/windfonts
```

3. 配置 OSS 存储(可选):
3. 配置管理员认证:
```env
# NextAuth.js 认证配置
AUTH_SECRET=your-super-secret-key-change-this-in-production
NEXTAUTH_URL=http://localhost:3000

# 管理员账号配置
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_EMAIL=admin@windfonts.com
```

4. 配置 OSS 存储(可选):
```env
# 阿里云 OSS 配置
OSS_ACCESS_KEY_ID=your_access_key
@ -105,6 +120,15 @@ yarn dev

访问 [http://localhost:3000](http://localhost:3000) 查看应用。

### 管理员登录
访问 [http://localhost:3000/login](http://localhost:3000/login) 进入管理员登录页面。

默认管理员账号:
- 用户名:`admin`
- 密码:`admin123`

登录成功后可访问 [http://localhost:3000/admin](http://localhost:3000/admin) 进入管理后台。

## 📜 可用脚本

### 开发和构建
@ -122,20 +146,26 @@ yarn dev
next-windfonts/
├── app/ # Next.js App Router 页面
│ ├── api/ # API 路由
│ │ ├── auth/ # NextAuth.js 认证 API
│ │ ├── css/ # 字体 CSS API
│ │ └── fonts/ # 字体管理 API
│ ├── admin/ # 管理后台页面
│ ├── admin/ # 管理后台页面(需要认证)
│ ├── login/ # 管理员登录页面
│ ├── fonts/ # 字体展示页面
│ └── docs/ # 文档页面
├── src/
│ ├── components/ # React 组件
│ │ ├── admin/ # 管理后台组件
│ │ └── providers/ # Context 提供者(包括 SessionProvider
│ ├── lib/ # 核心库和服务
│ │ ├── auth.ts # NextAuth.js 配置
│ │ ├── database/ # 数据库配置和 Schema
│ │ ├── services/ # 业务逻辑服务
│ │ └── config/ # 配置文件
│ ├── hooks/ # 自定义 React Hooks
│ ├── store/ # Redux 状态管理
│ └── utils/ # 工具函数
├── middleware.ts # Next.js 中间件(路由保护)
├── drizzle/ # 数据库迁移文件
├── posts/ # MDX 文档内容
└── public/ # 静态资源
@ -143,6 +173,23 @@ next-windfonts/

## 🔧 配置说明

### 安全配置

#### AUTH_SECRET 生成
为了确保认证系统的安全性,请生成一个强随机密钥:

```bash
# 使用 OpenSSL 生成随机密钥
openssl rand -base64 32
```

将生成的密钥设置为 `AUTH_SECRET` 环境变量的值。

#### 管理员账号安全
- 生产环境中请务必修改默认的管理员用户名和密码
- 建议使用强密码,包含大小写字母、数字和特殊字符
- 定期更换管理员密码

### 字体分类
- 无衬线字体
- 衬线

View file

@ -0,0 +1,22 @@
/*
* Copyright (C) 2024 WindFonts Project
*
* This file is part of WindFonts.
*
* WindFonts is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* WindFonts is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with WindFonts. If not, see <https://www.gnu.org/licenses/>.
*/

import { handlers } from '@/lib/auth';

export const { GET, POST } = handlers;

View file

@ -3,6 +3,7 @@ import React, { ReactNode } from 'react';
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
import { Metadata } from 'next';
import { theme } from '@/theme';
import { SessionProvider } from '@/components/providers/SessionProvider';

import '@/styles/globals.css';

@ -56,7 +57,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<ColorSchemeScript />
</head>
<body>
<MantineProvider theme={theme}>{children}</MantineProvider>
<SessionProvider>
<MantineProvider theme={theme}>{children}</MantineProvider>
</SessionProvider>
</body>
</html>
);

132
app/login/page.tsx Normal file
View file

@ -0,0 +1,132 @@
/*
* Copyright (C) 2024 WindFonts Project
*
* This file is part of WindFonts.
*
* WindFonts is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* WindFonts is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with WindFonts. If not, see <https://www.gnu.org/licenses/>.
*/

'use client';

import { useState } from 'react';
import { signIn, getSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import {
Container,
Paper,
TextInput,
PasswordInput,
Button,
Title,
Text,
Alert,
Stack,
Center,
} from '@mantine/core';
import { IconAlertCircle, IconLock } from '@tabler/icons-react';

export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');

try {
const result = await signIn('credentials', {
username,
password,
redirect: false,
});

if (result?.error) {
setError('用户名或密码错误');
} else {
// 验证登录状态
const session = await getSession();
if (session) {
router.push('/admin');
}
}
} catch (err) {
setError('登录过程中发生错误,请重试');
} finally {
setLoading(false);
}
};

return (
<Container size={420} my={40}>
<Center>
<Stack align="center" gap="md">
<IconLock size={48} color="var(--mantine-color-blue-6)" />
<Title order={2} ta="center">
WindFonts 管理后台
</Title>
<Text c="dimmed" size="sm" ta="center">
请输入管理员凭据以访问后台管理系统
</Text>
</Stack>
</Center>

<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={handleSubmit}>
<Stack gap="md">
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
color="red"
variant="light"
>
{error}
</Alert>
)}

<TextInput
label="用户名"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
required
disabled={loading}
/>

<PasswordInput
label="密码"
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
disabled={loading}
/>

<Button
type="submit"
fullWidth
loading={loading}
mt="md"
>
登录
</Button>
</Stack>
</form>
</Paper>
</Container>
);
}

47
middleware.ts Normal file
View file

@ -0,0 +1,47 @@
/*
* Copyright (C) 2024 WindFonts Project
*
* This file is part of WindFonts.
*
* WindFonts is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* WindFonts is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with WindFonts. If not, see <https://www.gnu.org/licenses/>.
*/

import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';

export default auth((req: NextRequest & { auth: any }) => {
const { pathname } = req.nextUrl;

// 保护 /admin 路由
if (pathname.startsWith('/admin')) {
if (!req.auth) {
// 未登录,重定向到登录页面
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}

// 检查用户角色
if (req.auth.user?.role !== 'admin') {
// 非管理员,重定向到首页
return NextResponse.redirect(new URL('/', req.url));
}
}

return NextResponse.next();
});

export const config = {
matcher: ['/admin/:path*'],
};

View file

@ -39,6 +39,7 @@
"lodash-es": "^4.17.21",
"modern-screenshot": "^4.4.39",
"next": "14.2.4",
"next-auth": "^5.0.0-beta.29",
"next-compose-plugins": "^2.2.1",
"next-contentlayer2": "^0.5.0",
"pg": "^8.16.3",

View file

@ -1,12 +1,31 @@
'use client';

import { NavLink } from '@mantine/core';
import { NavLink, Stack, Text, Button, Divider, Group } from '@mantine/core';
import { usePathname } from 'next/navigation';
import { useSession, signOut } from 'next-auth/react';
import { IconLogout, IconUser } from '@tabler/icons-react';

export default function Sidebar() {
const pathname = usePathname();
const { data: session } = useSession();

const handleSignOut = async () => {
await signOut({ callbackUrl: '/' });
};

return (
<>
<Stack gap="md">
{/* 用户信息 */}
<Group gap="xs">
<IconUser size={16} />
<Text size="sm" fw={500}>
{session?.user?.name || '管理员'}
</Text>
</Group>

<Divider />

{/* 导航菜单 */}
<NavLink
href="/admin/manage"
label="字体管理"
@ -17,6 +36,19 @@ export default function Sidebar() {
label="字体上传"
active={pathname === '/admin/upload'}
/>
</>

<Divider />

{/* 登出按钮 */}
<Button
variant="light"
color="red"
size="sm"
leftSection={<IconLogout size={16} />}
onClick={handleSignOut}
>
退出登录
</Button>
</Stack>
);
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (C) 2024 WindFonts Project
*
* This file is part of WindFonts.
*
* WindFonts is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* WindFonts is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with WindFonts. If not, see <https://www.gnu.org/licenses/>.
*/

'use client';

import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react';
import { ReactNode } from 'react';

interface SessionProviderProps {
children: ReactNode;
}

export function SessionProvider({ children }: SessionProviderProps) {
return (
<NextAuthSessionProvider>
{children}
</NextAuthSessionProvider>
);
}

75
src/lib/auth.ts Normal file
View file

@ -0,0 +1,75 @@
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

declare module 'next-auth' {
interface User {
role: string;
}

interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
role: string;
};
}
}

declare module '@auth/core/jwt' {
interface JWT {
role: string;
}
}

const config = {
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
username: { label: 'Username', type: 'text' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.username || !credentials?.password) {
return null;
}

// 验证管理员账号
if (
credentials.username === process.env.ADMIN_USERNAME &&
credentials.password === process.env.ADMIN_PASSWORD
) {
return {
id: '1',
name: 'Administrator',
email: process.env.ADMIN_EMAIL || 'admin@windfonts.com',
role: 'admin',
};
}

return null;
},
}),
],
session: {
strategy: 'jwt' as const,
},
callbacks: {
jwt: async ({ token, user }: { token: any; user: any }) => {
if (user) {
token.role = user.role;
}
return token;
},
session: async ({ session, token }: { session: any; token: any }) => {
session.user.role = token.role as string;
return session;
},
},
pages: {
signIn: '/login',
},
};

export const { handlers, auth, signIn, signOut } = NextAuth(config);

View file

@ -219,6 +219,30 @@ __metadata:
languageName: node
linkType: hard

"@auth/core@npm:0.40.0":
version: 0.40.0
resolution: "@auth/core@npm:0.40.0"
dependencies:
"@panva/hkdf": "npm:^1.2.1"
jose: "npm:^6.0.6"
oauth4webapi: "npm:^3.3.0"
preact: "npm:10.24.3"
preact-render-to-string: "npm:6.5.11"
peerDependencies:
"@simplewebauthn/browser": ^9.0.1
"@simplewebauthn/server": ^9.0.2
nodemailer: ^6.8.0
peerDependenciesMeta:
"@simplewebauthn/browser":
optional: true
"@simplewebauthn/server":
optional: true
nodemailer:
optional: true
checksum: 10c0/25cd12f22611eedc21c17dc1908fa9428ae5f0e32eb32c1ab009642276c37099cce58f49ffbb7f8e8d6d6488d5101a24fb9808ec662eee5aca19d520750acaa3
languageName: node
linkType: hard

"@babel/code-frame@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/code-frame@npm:7.27.1"
@ -2266,6 +2290,13 @@ __metadata:
languageName: node
linkType: hard

"@panva/hkdf@npm:^1.2.1":
version: 1.2.1
resolution: "@panva/hkdf@npm:1.2.1"
checksum: 10c0/1fabdec9bd2c19b8e88a3fa6fd0c25e25823c5000d9efdf4b6dfe32e9f370f8b9603cf776d120d160bec15fba17e079974cc34f0f52cebb24602cd832dfde19c
languageName: node
linkType: hard

"@parcel/watcher-android-arm64@npm:2.5.1":
version: 2.5.1
resolution: "@parcel/watcher-android-arm64@npm:2.5.1"
@ -7272,6 +7303,13 @@ __metadata:
languageName: node
linkType: hard

"jose@npm:^6.0.6":
version: 6.1.0
resolution: "jose@npm:6.1.0"
checksum: 10c0/f4518579e907317e144facd15c7627acd06097bbea17735097437217498aa419564c039dd4020f6af5f2d024a7cee6b7be4648ccbbdc238aedb80a47c061217d
languageName: node
linkType: hard

"js-base64@npm:^2.5.2":
version: 2.6.4
resolution: "js-base64@npm:2.6.4"
@ -8877,6 +8915,28 @@ __metadata:
languageName: node
linkType: hard

"next-auth@npm:^5.0.0-beta.29":
version: 5.0.0-beta.29
resolution: "next-auth@npm:5.0.0-beta.29"
dependencies:
"@auth/core": "npm:0.40.0"
peerDependencies:
"@simplewebauthn/browser": ^9.0.1
"@simplewebauthn/server": ^9.0.2
next: ^14.0.0-0 || ^15.0.0-0
nodemailer: ^6.6.5
react: ^18.2.0 || ^19.0.0-0
peerDependenciesMeta:
"@simplewebauthn/browser":
optional: true
"@simplewebauthn/server":
optional: true
nodemailer:
optional: true
checksum: 10c0/2c6bada9a5f28a9a172d3ad295bfb05b648a4fced01f9988154df1ebca712cf460fb49173ada4c26de4c7ab180256f40ac19d16e2147c1c68f2a7475ab5d5ea8
languageName: node
linkType: hard

"next-compose-plugins@npm:^2.2.1":
version: 2.2.1
resolution: "next-compose-plugins@npm:2.2.1"
@ -8964,6 +9024,7 @@ __metadata:
lodash-es: "npm:^4.17.21"
modern-screenshot: "npm:^4.4.39"
next: "npm:14.2.4"
next-auth: "npm:^5.0.0-beta.29"
next-compose-plugins: "npm:^2.2.1"
next-contentlayer2: "npm:^0.5.0"
node-loader: "npm:^2.1.0"
@ -9188,6 +9249,13 @@ __metadata:
languageName: node
linkType: hard

"oauth4webapi@npm:^3.3.0":
version: 3.8.1
resolution: "oauth4webapi@npm:3.8.1"
checksum: 10c0/2dad6d39d4830efe68d542e8e131fd5b15d5a864f96ad7189263da9763cad0e22481af72e50d64d58ab62887ba43488bff5d33952426c5d197089cc7c59b2a45
languageName: node
linkType: hard

"object-assign@npm:^4.0.1, object-assign@npm:^4.1.1":
version: 4.1.1
resolution: "object-assign@npm:4.1.1"
@ -9892,6 +9960,22 @@ __metadata:
languageName: node
linkType: hard

"preact-render-to-string@npm:6.5.11":
version: 6.5.11
resolution: "preact-render-to-string@npm:6.5.11"
peerDependencies:
preact: ">=10"
checksum: 10c0/a68b704c1343756022fb41322154b45e39f6021b940cbc97e86c292627d017d019700339a17d67b42c308b14fc5323fc120c798f785d6a3e993c8f273610bfe2
languageName: node
linkType: hard

"preact@npm:10.24.3":
version: 10.24.3
resolution: "preact@npm:10.24.3"
checksum: 10c0/c863df6d7be6a660480189762d8a8f2d4148733fc2bb9efbd9d2fd27315d2c7ede850a16077d716c91666c915c0349bd3c9699733e4f08457226a0519f408761
languageName: node
linkType: hard

"prelude-ls@npm:^1.2.1":
version: 1.2.1
resolution: "prelude-ls@npm:1.2.1"