feat(认证): 添加认证状态管理和路由保护

- 实现 AuthProvider 管理全局认证状态
- 实现 middleware 路由保护中间件
- 支持受保护路由自动重定向到登录页
- 支持已登录用户自动跳转首页
This commit is contained in:
gaoziman 2025-12-19 22:36:21 +08:00
parent 733c93a91c
commit b0b912274f
3 changed files with 323 additions and 0 deletions

84
src/middleware.ts Normal file
View File

@ -0,0 +1,84 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
// JWT 密钥
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || 'lioncode-jwt-secret-key-2024'
);
// Cookie 名称
const AUTH_COOKIE_NAME = 'lioncode_auth_token';
// 需要登录才能访问的路由
const protectedRoutes = ['/', '/chat', '/settings'];
// 公开路由(已登录用户访问会重定向到首页)
const publicRoutes = ['/login', '/register', '/reset-password'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 忽略 API 路由和静态资源
if (
pathname.startsWith('/api/') ||
pathname.startsWith('/_next/') ||
pathname.startsWith('/favicon') ||
pathname.includes('.')
) {
return NextResponse.next();
}
// 获取认证 Cookie
const token = request.cookies.get(AUTH_COOKIE_NAME)?.value;
// 验证 Token
let isAuthenticated = false;
if (token) {
try {
await jwtVerify(token, JWT_SECRET);
isAuthenticated = true;
} catch {
// Token 无效或过期
isAuthenticated = false;
}
}
// 检查是否是受保护的路由
const isProtectedRoute = protectedRoutes.some(
(route) => pathname === route || pathname.startsWith(`${route}/`)
);
// 检查是否是公开路由
const isPublicRoute = publicRoutes.some(
(route) => pathname === route || pathname.startsWith(`${route}/`)
);
// 未登录用户访问受保护路由 -> 重定向到登录页
if (isProtectedRoute && !isAuthenticated) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
// 已登录用户访问公开路由 -> 重定向到首页
if (isPublicRoute && isAuthenticated) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
// 配置匹配的路由
export const config = {
matcher: [
/*
* :
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};

View File

@ -0,0 +1,238 @@
'use client';
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from 'react';
import { useRouter, usePathname } from 'next/navigation';
// 用户类型
export interface AuthUser {
id: string;
email: string;
nickname: string;
plan: string;
avatar?: string | null;
status?: string;
emailVerified?: boolean;
lastLoginAt?: string | null;
createdAt?: string;
}
// 认证上下文类型
interface AuthContextType {
user: AuthUser | null;
loading: boolean;
login: (email: string, password?: string, code?: string, loginType?: 'password' | 'code') => Promise<{ success: boolean; error?: string }>;
register: (email: string, password: string, confirmPassword: string, nickname: string, code: string) => Promise<{ success: boolean; error?: string }>;
logout: () => Promise<void>;
sendCode: (email: string, type: 'register' | 'login' | 'reset') => Promise<{ success: boolean; error?: string }>;
resetPassword: (email: string, code: string, newPassword: string, confirmPassword: string) => Promise<{ success: boolean; error?: string }>;
refreshUser: () => Promise<void>;
}
// 创建上下文
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Provider Props
interface AuthProviderProps {
children: ReactNode;
}
// 公开路由(不需要获取用户信息)
const publicRoutes = ['/login', '/register', '/reset-password'];
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
const pathname = usePathname();
// 获取当前用户信息
const fetchUser = useCallback(async () => {
// 公开路由不需要获取用户信息
if (publicRoutes.some(route => pathname?.startsWith(route))) {
setLoading(false);
return;
}
try {
const response = await fetch('/api/auth/me');
const data = await response.json();
if (data.success && data.user) {
setUser(data.user);
} else {
setUser(null);
}
} catch (error) {
console.error('获取用户信息失败:', error);
setUser(null);
} finally {
setLoading(false);
}
}, [pathname]);
// 初始化时获取用户信息
useEffect(() => {
fetchUser();
}, [fetchUser]);
// 刷新用户信息
const refreshUser = useCallback(async () => {
await fetchUser();
}, [fetchUser]);
// 登录
const login = useCallback(async (
email: string,
password?: string,
code?: string,
loginType: 'password' | 'code' = 'password'
) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, code, loginType }),
});
const data = await response.json();
if (data.success) {
setUser(data.user);
router.push('/');
return { success: true };
}
return { success: false, error: data.error };
} catch (error) {
console.error('登录失败:', error);
return { success: false, error: '登录失败,请稍后重试' };
}
}, [router]);
// 注册
const register = useCallback(async (
email: string,
password: string,
confirmPassword: string,
nickname: string,
code: string
) => {
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, confirmPassword, nickname, code }),
});
const data = await response.json();
if (data.success) {
setUser(data.user);
router.push('/');
return { success: true };
}
return { success: false, error: data.error };
} catch (error) {
console.error('注册失败:', error);
return { success: false, error: '注册失败,请稍后重试' };
}
}, [router]);
// 登出
const logout = useCallback(async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
setUser(null);
router.push('/login');
} catch (error) {
console.error('登出失败:', error);
}
}, [router]);
// 发送验证码
const sendCode = useCallback(async (
email: string,
type: 'register' | 'login' | 'reset'
) => {
try {
const response = await fetch('/api/auth/send-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, type }),
});
const data = await response.json();
if (data.success) {
return { success: true };
}
return { success: false, error: data.error };
} catch (error) {
console.error('发送验证码失败:', error);
return { success: false, error: '发送验证码失败,请稍后重试' };
}
}, []);
// 重置密码
const resetPassword = useCallback(async (
email: string,
code: string,
newPassword: string,
confirmPassword: string
) => {
try {
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code, newPassword, confirmPassword }),
});
const data = await response.json();
if (data.success) {
router.push('/login');
return { success: true };
}
return { success: false, error: data.error };
} catch (error) {
console.error('重置密码失败:', error);
return { success: false, error: '重置密码失败,请稍后重试' };
}
}, [router]);
const value: AuthContextType = {
user,
loading,
login,
register,
logout,
sendCode,
resetPassword,
refreshUser,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// 自定义 Hook
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

1
src/providers/index.ts Normal file
View File

@ -0,0 +1 @@
export { AuthProvider, useAuth, type AuthUser } from './AuthProvider';