在构建电商平台的过程中,用户登录是一个不可或缺的核心功能。
本文将详细介绍如何在 React 前端实现登录表单组件,并结合 NestJS 后端完成完整的用户认证流程,包括 JWT 认证、记住我功能以及登录状态持久化等关键特性。
1. 小改动
1.1. 改变量/字段名字
将 React 项目的 src/stores/user/types.ts
中的 LoginCredentials
的 email
修改为 username
:
ts
1 2 3 4
| export interface LoginCredentials { username: string; password: string; }
|
1.2. 修改依赖数组
在 src/pages/VerifyEmail.tsx
中的 useEffect
的依赖数组内加一个 location.search
:
VerifyEmail.tsxtsx
1 2 3 4 5 6 7 8 9 10 11
| useEffect(() => { const searchParams = new URLSearchParams(location.search); const token = searchParams.get('token'); const currentToken = useUserStore.getState().verificationToken;
if (token && !verificationInProgress && !emailVerified && token !== currentToken) { handleVerification(token).then(r => { if (r.success && 'message' in r) console.log('邮箱验证成功:', r.message); }); } }, [handleVerification, verificationInProgress, emailVerified, location.search]);
|
1.3. 新建 email-verification.interface.ts
将原本放在 users.service.ts
内的这段内容单独放在一个文件里:
email-verification.interface.tsts
1 2 3 4 5 6 7 8 9 10 11 12
| export enum EmailVerificationError { TOKEN_INVALID = 'TOKEN_INVALID', TOKEN_EXPIRED = 'TOKEN_EXPIRED', ALREADY_VERIFIED = 'ALREADY_VERIFIED' }
export interface VerificationResponse { success: boolean; message: string; error?: EmailVerificationError; userId?: string; }
|
1.4. 统一查找用户的方法
原先我们只写了靠 ID 来查找用户的方法,如果要进行扩展的话,一个方法一个方法写太麻烦了,干脆写一个更灵活的查找方法、支持通过任何唯一字段查找用户:
users.service.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| async findUser(criteria: UserSearchCriteria) { this.logger.debug(`开始查找用户,条件:${JSON.stringify(criteria)}`);
const whereClause = Object.entries(criteria) .filter(([, value]) => value !== undefined) .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
if (Object.keys(whereClause).length === 0) { this.logger.warn('查询条件为空'); throw new BadRequestException('至少需要一个查询条件'); }
const user = await this.usersRepository.findOne({ where: whereClause });
if (!user) { const [[key, value]] = Object.entries(whereClause); const fieldMap = { id: 'ID', username: '用户名', email: '邮箱', verificationToken: '验证令牌' }; const errorMessage = `未找到${fieldMap[key]}为 ${value} 的用户`; this.logger.warn(`查找用户失败:${errorMessage}`); throw new NotFoundException(errorMessage); }
this.logger.debug(`用户查找成功:${user.id},用户详细信息:${JSON.stringify(user)}`); return user; }
|
users.interface.tsts
1 2 3 4 5 6
| export type UserSearchCriteria = Partial<{ id: string; username: string; email: string; verificationToken: string; }>;
|
UsersController
的查找用户 API 也可以修改成类似的样子:
users.controller.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @ApiOperation({ summary: '根据不同字段查找用户' }) @ApiParam({ name: 'field', enum: ['id', 'username', 'email'], description: '查找字段' }) @ApiParam({ name: 'value', description: '查找值' }) @ApiResponse({ status: HttpStatus.OK, description: '获取用户信息成功', type: Users }) @HttpCode(HttpStatus.OK) @Get(':field/:value') async findByField(@Param('field') field: 'id' | 'username' | 'email', @Param('value') value: string): Promise<Users> { const validFields = ['id', 'username', 'email']; if (!validFields.includes(field)) { throw new BadRequestException(`不支持通过 ${field} 字段查找用户`); }
return this.usersService.findUser({ [field]: value }); }
|
1.5. JWT Payload
修复
jwt-payload.interface.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| export interface JWTPayload { sub: number; username: string; email: string; role: string; iat?: number; exp?: number; aud?: string; iss?: string; }
export interface JWTPayload { sub: string; username: string; email: string; role: string; verified: boolean; iat?: number; exp?: number; aud?: string; iss?: string; }
|
1.6. 布局组件优化
为了更好地适应不同的页面布局需求,我们需要增加主布局组件的灵活性。原有的 MainLayout
组件只支持路由渲染:
MainLayout.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const MainLayout = () => { return ( <div> <header className="bg-primary shadow"> <nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> {/* TODO: 导航内容 */} </nav> </header>
<main> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <Outlet /> </div> </main> </div> ); };
|
我们对其进行了改造,使其同时支持直接的子组件渲染:
MainLayout.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import React from 'react'; import { Outlet } from 'react-router-dom';
interface MainLayoutProps { children?: React.ReactNode; }
const MainLayout = ({ children }: MainLayoutProps) => { return ( <div> <header className="bg-primary shadow"> <nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> {/* TODO: 导航内容 */} </nav> </header>
<main> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> { children ? children : <Outlet /> } </div> </main> </div> ); };
export default MainLayout;
|
2. 实现登录表单组件
在电商平台中,我们需要一个用户友好的登录界面,让用户能够:
- 输入用户名和密码进行登录
- 在输入过程中获得适当的表单验证反馈
- 看到登录状态的加载提示
- 收到登陆成功或者失败的明确提示
-
让我们先在 src/pages
目录下创建 Login.tsx
:
Login.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import React from 'react'; import { useFormik } from 'formik'; import * as Yup from 'yup'; import { useNavigate } from 'react-router-dom'; import { useUserStore } from '../stores'; import AuthLayout from '../layouts/AuthLayout'; import logo from '../assets/ShoppingNest.png';
const Login = () => { const navigate = useNavigate(); const login = useUserStore(state => state.login); const isLoading = useUserStore(state => state.isLoading); const error = useUserStore(state => state.error);
return ( ) } export default Login;
|
-
使用 Formik 管理表单状态和处理提交:
Login.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const formik = useFormik({ initialValues: { username: '', password: '' }, validationSchema, onSubmit: async (values) => { try { const { username, password } = values; await login({ username, password }); navigate('/', { state: { message: '登陆成功!' } }) } catch (error) { console.error('登录失败:', error); } } });
|
-
使用 Yup 进行表单验证,确保用户名和密码不为空:
Login.tsxtsx
1 2 3 4
| const validationSchema = Yup.object().shape({ username: Yup.string().required('请输入用户名!'), password: Yup.string().required('请输入密码!') });
|
-
以下是页面的基本结构:
tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
| return ( <AuthLayout> <img src={logo} className="w-1/4 mx-auto" alt="Shopping Nest的Logo" /> <h2 className="text-2xl font-bold text-center m-6 text-neutral-content"> 登录用户 </h2>
{error && ( <div className="mb-4 p-3 text-sm text-error-content bg-error rounded-lg"> {error} </div> )}
<form onSubmit={formik.handleSubmit}> <div className="mb-4"> <label className="block text-sm font-semibold text-neutral-content" htmlFor="username"> 用户名 </label> <input type="text" id="username" name="username" className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${ formik.touched.username && formik.errors.username ? "border-error" : "border-neutral-600" } text-base-content`} value={formik.values.username} onChange={formik.handleChange} onBlur={formik.handleBlur} disabled={isLoading} /> {formik.touched.username && formik.errors.username && ( <p className="text-sm text-error mt-1">{formik.errors.username}</p> )} </div>
<div className="mb-4"> <label className="block text-sm font-semibold text-neutral-content" htmlFor="password"> 密码 </label> <input type="password" id="password" name="password" className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${ formik.touched.password && formik.errors.password ? "border-error" : "border-neutral-600" } text-base-content`} value={formik.values.password} onChange={formik.handleChange} onBlur={formik.handleBlur} disabled={isLoading} /> {formik.touched.password && formik.errors.password && ( <p className="text-sm text-error mt-1">{formik.errors.password}</p> )} </div>
<button type="submit" className="w-full bg-primary text-primary-content py-2 rounded-lg mt-4 hover:brightness-90 disabled:opacity-50" disabled={isLoading} > {isLoading ? "登录中…" : "登录"} </button> </form> </AuthLayout> )
|
-
在 src/router.tsx
中添加 /login
路由:
router.tsxtsx
1 2 3 4 5 6 7 8
| import Login from './pages/Login';
const router = createBrowserRouter([ { path: '/login', element: <Login /> } ]);
|
3. 配置 JWT 认证
在实现用户登录功能时,我们需要一个安全可靠的身份验证机制,以确保用户身份的真实性并保护系统的安全。
JWT(JSON Web Token)是一个开放标准,它提供了一种紧凑且自包含的方式,在各方之间安全地传输信息。
通过 JWT,我们可以生成安全的访问令牌、验证用户的身份和权限、保护需要认证的 API 端点,并在服务端对令牌的有效性进行校验。
要实现 JWT 认证机制,我们需要从多个方面入手:
- 配置认证模块、JWT 模块的签名密钥以及相关选项
- 集成用户模块,以便进行用户身份验证
- 实现认证服务,该服务需要负责验证用户凭据、生成 JWT 令牌、处理令牌刷新以及检查令牌的有效性,确保认证流程的完整性和安全性
- 创建一个认证控制器,专门处理用户的登录请求,并返回认证结果和 JWT 令牌,确保前端能够正确接收和使用身份验证信息
- 实现 JWT 策略和守卫:从请求中提取 JWT 令牌,并验证其有效性,以此来保护系统中需要认证的路由,确保只有经过身份经验的用户才能访问受保护的资源
3.1. 安装依赖
bash
1 2
| yarn add @nestjs/jwt @nestjs/passport passport passport-jwt yarn add -D @types/passport-jwt
|
3.2. 认证模块
在 src
目录下创建 auth
目录,并创建 auth.module.ts
:
auth.module.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| import { Module } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; import { UsersModule } from '../users/users.module';
@Module({ imports: [ PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ inject: [ConfigService], useFactory: async (configService: ConfigService) => ({ secret: configService.get<string>('JWT_SECRET'), signOptions: { audience: configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: configService.get<string>('JWT_TOKEN_ISSUER'), expiresIn: configService.get<number>('JWT_ACCESS_TOKEN_TTL') } }) }), UsersModule ], controllers: [AuthController], providers: [AuthService, JwtStrategy], exports: [AuthService] }) export class AuthModule {}
|
3.3. 用户验证和令牌生成
创建 auth.service.ts
:
auth.service.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; import { UsersService } from '../users/users.service'; import { JWTPayload } from './interfaces/jwt-payload.interface';
@Injectable() export class AuthService { constructor( private jwtService: JwtService, private usersService: UsersService, private configService: ConfigService ) {}
}
|
-
验证用户:
auth.service.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| async validateUser(username: string, password: string) { const user = await this.usersService.findUser({ username: username }); if (!user) { throw new UnauthorizedException('用户名或密码错误'); }
if (!user.verified) { throw new UnauthorizedException('请先验证邮箱后再登录'); }
const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { throw new UnauthorizedException('用户名或密码错误'); }
return user; }
|
-
用户登录:
auth.service.tsts
1 2 3 4 5 6 7
| async login(user: any) { const payload: JWTPayload = { sub: user.sub, username: user.username, email: user.email, role: user.role };
|
首先构建 JWT 载荷。
JWT 基本上由三个部分组成,使用 .
分割:
plaintext
-
头包含了令牌类型和使用的签名算法:
json
1 2 3 4
| { "alg": "HS256", "typ": "JWT" }
|
-
载荷包含了我们存储的实际数据:
json
1 2 3 4 5 6 7 8
| { "sub": "1234567890", "username": "john_doe", "email": "[email protected]", "role": "user", "iat": 1516239022, "exp": 1516242622 }
|
-
签名则使用密钥、对头和载荷进行签名:
javascript
1 2 3 4 5
| HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )
|
auth.service.tsts
1 2 3 4 5 6 7 8
| const accessToken = this.jwtService.sign(payload, { audience: this.configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: this.configService.get<string>('JWT_TOKEN_ISSUER'), expiresIn: this.configService.get<number>('JWT_ACCESS_TOKEN_TTL'), });
|
使用 JwtService
生成签名令牌。
auth.service.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13
| return { access_token: accessToken, token_type: 'Bearer', expires_in: this.configService.get<number>('JWT_ACCESS_TOKEN_TTL'), user: { id: user.id, username: user.username, email: user.email, role: user.role, verified: user.verified } }; }
|
这里说一下令牌类型 Bearer
,它是 OAuth 2.0 的标准组成部分。
-
在令牌过期前更新令牌:
auth.service.tsts
1 2 3 4 5 6 7 8 9 10 11 12
| async refreshToken(user: any) { const payload: JWTPayload = { sub: user.id, username: user.username, email: user.email, role: user.role };
return { access_token: this.jwtService.sign(payload) }; }
|
当现有访问令牌即将过期时,服务器会生成一个新的访问令牌。
使用与原始登录相同的用户信息构建新的载荷,这样用户就不需要重新登陆,可以无缝继续使用系统。
-
验证令牌:
auth.service.tsts
1 2 3 4 5 6 7
| verifyToken(token: string) { try { return this.jwtService.verify(token); } catch (error) { throw new UnauthorizedException('无效的令牌,错误:', error); } }
|
这个方法负责验证传入的 JWT 令牌的有效性。
JwtService.verify()
方法会检查令牌是否:
- 签名有效(未被篡改)
- 未过期
- 是由我们的系统签发的
在 auth
目录下创建 interfaces/jwt-payload.interface.ts
文件,定义 JWT 载荷的 TypeScript 接口:
jwt-payload.interface.tsts
1 2 3 4 5 6 7 8 9 10
| export interface JWTPayload { sub: number; username: string; email: string; role: string; iat?: number; exp?: number; aud?: string; iss?: string; }
|
3.4. 认证控制器
我们先来写一下登录所需要的 DTO。
-
定义登录请求的数据结构:
login.dto.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class LoginDto { @ApiProperty({ example: 'johndoe', description: '用户名' }) @IsString() @IsNotEmpty({ message: '用户名不能为空' }) username: string;
@ApiProperty({ example: 'password123', description: '密码' }) @IsString() @IsNotEmpty({ message: '密码不能为空' }) @MinLength(6, { message: '密码长度不能小于6位' }) password: string; }
|
-
登录响应 DTO:
login-response.dto.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| import { ApiProperty } from '@nestjs/swagger';
export class LoginResponseDto { @ApiProperty({ description: '访问令牌' }) access_token: string;
@ApiProperty({ description: '令牌类型', example: 'Bearer' }) token_type: string;
@ApiProperty({ description: '过期时间(秒)', example: 3600 }) expires_in: number;
@ApiProperty({ description: '用户信息', example: { id: '1', username: 'johndoe', email: '[email protected]', role: 'user', verified: true } }) user: { id: string; username: string; email: string; role: string; verified: boolean; }; }
|
创建 auth.controller.ts
:
auth.controller.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| import { Controller, Post, Body, HttpCode, HttpStatus, UnauthorizedException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; import { LoginResponseDto } from './dto/login-response.dto'; import winstonLogger from '../loggers/winston.logger';
@ApiTags('认证') @Controller('auth') export class AuthController { private readonly logger = winstonLogger;
constructor(private readonly authService: AuthService) {}
@ApiOperation({ summary: '用户登录' }) @ApiResponse({ status: HttpStatus.OK, description: '登录成功', type: LoginResponseDto }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: '登录失败' }) @HttpCode(HttpStatus.OK) @Post('login') async login(@Body() loginDto: LoginDto): Promise<LoginResponseDto> { try { const user = await this.authService.validateUser(loginDto.username, loginDto.password);
this.logger.debug(`用户 ${loginDto.username} 验证通过,正在生成令牌`); const loginResult = await this.authService.login(user); this.logger.info(`用户 ${loginDto.username} 登录成功`); return loginResult; } catch (error) { this.logger.error(`用户 ${loginDto.username} 登录失败: ${error.message}`); throw error; } } }
|
3.5. 策略实现
AuthService.validateUser()
方法仅是用于登录时的用户验证,以及生成初始的 JWT 令牌。
那后续请求该由谁来验证呢?
可以想象成有这么一个商场。
你在前台登记(登录),工作人员会验证你的身份(AuthService.validateUser()
),验证成功后给了你一个特殊的通行证(JWT 令牌)。
你在商场里遛弯,发现有一个 VIP 区域(访问受保护的 API),门口站一保安。你说你想进去,保安说别急先看看你的通行证(JWT 令牌)。
保安需要看到的是:
- 这个通行证是不是商场发的(JWT 签名验证)
- 通行证有没有过期(JWT 过期检查)
- 你的会员资格是否还有效(接下来要写的方法)
JWT 策略实现就是这个保安。
创建 jwt.strategy.ts
:
jwt.strategy.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ConfigService } from '@nestjs/config'; import { Strategy, ExtractJwt } from 'passport-jwt'; import { UsersService } from '../users/users.service';
@Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private configService: ConfigService, private usersService: UsersService ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: configService.get<string>('JWT_SECRET'), audience: configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: configService.get<string>('JWT_TOKEN_ISSUER') }); }
|
检查用户是否还是“有效会员”:
jwt.strategy.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| async validate(payload: any) { const user = await this.usersService.findUser({ id: payload.sub }); if (!user) { throw new UnauthorizedException('用户不存在'); } if (!user.verified) { throw new UnauthorizedException('用户未验证'); }
return { id: user.id, username: user.username, email: user.email, role: user.role }; } }
|
3.6. JWT 守卫
先前的商场例子里,提到了“受保护的 API”。
那么要如何创建这么个“VIP 区域”呢?我们就需要用到 JWT 守卫。
创建 jwt-auth.guard.ts
:
jwt-auth.guard.tsts
1 2 3 4 5 6 7 8 9
| @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { handleRequest(error: any, user: any, info: any) { if (error || !user) { throw new UnauthorizedException('验证失败'); } return user; } }
|
守卫的工作流程:
- 收到请求时,检查
Authorization
头部是否包含 JWT 令牌
- 如果没有令牌或令牌无效,直接拦截请求并返回
401
错误
- 如果令牌有效,让请求通过并继续处理
使用方式也很简单:
ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| @Controller('orders') export class OrdersController { constructor(private ordersService: OrdersService) {}
@Get('popular') getPopularOrders() { return this.ordersService.getPopularOrders(); }
@UseGuards(JwtAuthGuard) @Get('my-orders') getMyOrders(@CurrentUser() user: any) { return this.ordersService.getOrdersByUserId(user.id); }
@UseGuards(JwtAuthGuard) @Post('create') createOrder(@CurrentUser() user: any, @Body() orderData: any) { return this.ordersService.createOrder(user.id, orderData); } }
|
也可以直接保护控制器的所有路由:
ts
1 2 3 4 5 6 7 8 9 10 11 12 13
| @UseGuards(JwtAuthGuard) @Controller('profile') export class ProfileController { @Get() getProfile(@CurrentUser() user: any) { return user; }
@Put() updateProfile(@CurrentUser() user: any, @Body() updateData: any) { return this.profileService.update(user.id, updateData); } }
|
3.7. 用户装饰器
假设我们有一个购物车的 API,需要获取当前用户的购物车信息:
ts
1 2 3 4 5 6 7 8 9 10 11
| @Controller('cart') export class CartController { constructor(private cartService: CartService) {}
@UseGuards(JwtAuthGuard) @Get('my-cart') async getMyCart(@CurrentUser() user: any) { return this.cartService.getCartByUserId(user.id); } }
|
如果没有 CurrentUser
装饰器,我们需要:
ts
1 2 3 4 5
| @Get('my-cart') async getMyCart(@Request() req: any) { const user = req.user; return this.cartService.getCartByUserId(user.id); }
|
CurrentUser
装饰器的实现:
current-user.decorator.tsts
1 2 3 4 5 6
| import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); return request.user; });
|
它的好处在于:
- 简化代码:不需要手动从
request
中提取用户信息
- 类型安全:可以明确知道返回的是用户对象
- 可重用性:在任何需要当前用户信息的地方都可以使用
- 关注点分离:控制器方法只需要关心用户信息,不需要关心它是如何获取的
像是还可以有这样的使用场景:
ts
1 2 3 4 5 6 7 8
| @Controller('users') export class UsersController { @UseGuards(JwtAuthGuard) @Get('profile') getProfile(@CurrentUser() user: any) { return user; } }
|
4. 添加记住我功能
在现代电商平台中,用户体验是至关重要的。用户希望能够快速、便捷地访问他们的账户,而不需要每次都重新输入用户名和密码。这就是“记住我”功能的用武之地。
通过“记住我”功能,用户可以选择在登录时保持一段时间的登录状态,即使关闭浏览器或重新打开应用,用户仍然可以自动登录,无需再次输入凭证。
4.1. 技术方案设计
我们采用 RefreshToken
+ AccessToken
的双令牌认证方案:
AccessToken
:短期令牌,用于访问 API
RefreshToken
:长期令牌,用于刷新 AccessToken
这种方案的优势在于:
- 安全性高:
AccessToken
短期有效,即使泄露风险也较小
- 用户体验好:
RefreshToken
可以静默刷新 AccessToken
,用户无感知
- 可控性强:可以随时注销
RefreshToken
,确保账户安全
4.2. 环境配置
4.2.1. 修改环境变量
首先需要在 .env
文件中添加新的配置项:
.envplaintext
1 2 3 4 5 6 7
| # JWT JWT_ACCESS_SECRET=access_secret JWT_REFRESH_SECRET=refresh_secret JWT_TOKEN_AUDIENCE=localhost:4000 JWT_TOKEN_ISSUER=localhost:4000 JWT_ACCESS_TOKEN_TTL=3600 JWT_REFRESH_TOKEN_TTL=604800
|
配置说明:
JWT_ACCESS_SECRET
:访问令牌密钥
JWT_REFRESH_SECRET
:刷新令牌密钥
JWT_ACCESS_TOKEN_TTL
:访问令牌有效期(1 小时)
JWT_REFRESH_TOKEN_TTL
:刷新令牌有效期(7 天)
4.2.2. 更新配置验证
在 AppModule
中更新 ConfigModule
的配置验证:
app.module.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Module({ imports: [ ConfigModule.forRoot({ validationSchema: Joi.object({ JWT_ACCESS_SECRET: Joi.string().required(), JWT_REFRESH_SECRET: Joi.string().required(), JWT_TOKEN_AUDIENCE: Joi.string().required(), JWT_TOKEN_ISSUER: Joi.string().required(), JWT_ACCESS_TOKEN_TTL: Joi.number().default(3600), JWT_REFRESH_TOKEN_TTL: Joi.number().default(604800) }) }) ] })
|
4.2.3. 更新认证模块
在 AuthModule
中更新 JwtModule
的配置:
app.module.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Module({ imports: [ JwtModule.registerAsync({ inject: [ConfigService], useFactory: async (configService: ConfigService) => ({ secret: configService.get<string>('JWT_ACCESS_SECRET'), signOptions: { audience: configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: configService.get<string>('JWT_TOKEN_ISSUER'), expiresIn: configService.get<number>('JWT_ACCESS_TOKEN_TTL') } }) }) ] })
|
4.2.4. 更新 JWT 策略
修改 JwtStrategy
的配置:
jwt.strategy.tsts
1 2 3 4 5 6 7 8 9 10 11 12
| @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(private configService: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: configService.get<string>('JWT_ACCESS_SECRET'), audience: configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: configService.get<string>('JWT_TOKEN_ISSUER') }); } }
|
4.3. 令牌生成与签名实现
4.3.1. JWT 配置抽离
为了更好地管理 JWT 相关的配置,我们首先创建了一个专门的配置文件 jwt.config.ts
:
jwt.config.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { ConfigService } from '@nestjs/config';
export const getJWTVerifyOptions = (configService: ConfigService) => ({ accessToken: { secret: configService.get('JWT_ACCESS_SECRET'), audience: configService.get('JWT_TOKEN_AUDIENCE'), issuer: configService.get('JWT_TOKEN_ISSUER'), expiresIn: configService.get('JWT_ACCESS_TOKEN_TTL') }, refreshToken: { secret: configService.get('JWT_REFRESH_SECRET'), audience: configService.get('JWT_TOKEN_AUDIENCE'), issuer: configService.get('JWT_TOKEN_ISSUER'), expiresIn: configService.get('JWT_REFRESH_TOKEN_TTL') } });
|
这样做的好处是:
- 集中管理 JWT 配置
- 方便在不同地方复用配置
- 确保配置的一致性
4.3.2. 数据结构调整
-
在 Users
实体中添加 refreshToken
字段,用于存储刷新令牌:
users.entity.tsts
1 2
| @Column({ nullable: true }) refreshToken?: string;
|
-
在 LoginDto
中添加“记住我”选项:
login.dto.tsts
1 2 3 4
| @ApiProperty({ example: false, required: false, description: '是否记住我' }) @IsBoolean() @IsOptional() rememberMe?: boolean;
|
-
在 LoginResponseDto
中新增刷新令牌字段:
login-response.dto.tsts
1 2
| @ApiProperty({ description: '刷新令牌' }) refresh_token: string;
|
4.3.3. 认证服务实现
在 AuthService
中,我们需要进行以下主要改动:
-
为了支持“记住我”功能,需要在用户登录时生成两种令牌:访问令牌(accessToken
)和刷新令牌(refreshToken
)。
-
将原有的令牌生成逻辑修改为:
auth.service.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13
| const accessToken = this.jwtService.sign(payload, { audience: this.configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: this.configService.get<string>('JWT_TOKEN_ISSUER'), expiresIn: this.configService.get<number>('JWT_ACCESS_TOKEN_TTL') });
const accessToken = this.jwtService.sign(payload); const refreshToken = this.jwtService.sign( payload, getJWTVerifyOptions(this.configService).refreshToken );
|
-
在登录响应中添加刷新令牌:
auth.service.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| return { access_token: accessToken, token_type: 'Bearer', expires_in: this.configService.get<number>('JWT_ACCESS_TOKEN_TTL'), user: { ... } };
return { access_token: accessToken, refresh_token: refreshToken, token_type: 'Bearer', expires_in: this.configService.get<number>('JWT_ACCESS_TOKEN_TTL'), user: { ... } };
|
-
新增令牌刷新功能,用于在访问令牌过期后获取新的访问令牌:
auth.service.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| async refresh(refreshToken: string) { this.logger.debug(`刷新访问令牌`);
const refreshPayload = this.jwtService.verify( refreshToken, getJWTVerifyOptions(this.configService).refreshToken ); const user = await this.usersService.findUser({ id: refreshPayload.sub }); if (!user) { this.logger.warn(`刷新失败,未找到用户:${user.username}`); throw new UnauthorizedException('用户不存在'); }
const newAccessToken = this.jwtService.sign( { sub: user.id, username: user.username, email: user.email, role: user.role }, getJWTVerifyOptions(this.configService).accessToken );
this.logger.info(`已为用户刷新访问令牌:${user.username}`);
return { access_token: newAccessToken, token_type: 'Bearer', expires_in: this.configService.get<number>('JWT_REFRESH_TOKEN_TTL') }; }
|
-
添加登出功能,用于清除用户的刷新令牌:
auth.service.tsts
1 2 3
| async logout(userId: string): Promise<void> { await this.usersService.update(userId, { refreshToken: null }); }
|
4.3.4. 控制器层改造
在 AuthController
中,我们需要添加新的接口来支持“记住我”功能:
auth.controller.tsts
1 2 3 4 5 6 7 8 9 10 11 12
| @ApiOperation({ summary: '刷新访问令牌' }) @UseGuards(JwtRefreshGuard) @HttpCode(HttpStatus.OK) @Post('refresh') async refresh(@Body() body: { refreshToken: string }) { try { return await this.authService.refresh(body.refreshToken); } catch (error) { this.logger.error(`刷新 Token 失败:${error.message}`); throw new UnauthorizedException('刷新 Token 失败'); } }
|
同时添加获取当前用户信息和登出的接口:
auth.controller.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @ApiOperation({ summary: '获取当前用户信息' }) @HttpCode(HttpStatus.OK) @Get('me') async getCurrentUser(@CurrentUser() user: JWTPayload) { return this.authService.getCurrentUser(user.sub); }
@ApiOperation({ summary: '登出用户' }) @HttpCode(HttpStatus.OK) @UseGuards(JwtAuthGuard) @Post('logout') async logout(@Req() req: Request) { const user = req.user as JWTPayload; await this.authService.logout(user.sub); return { message: '已登出' }; }
|
4.3.5. 守卫机制完善
-
访问令牌守卫
原来的 JwtAuthGuard
实现存在几个问题:
- 没有直接验证
token
的有效性
- 没有正确处理
audience
和 issuer
的验证
看一眼原本的代码:
jwt-auth.guard.tsts
1 2 3 4 5 6 7 8 9
| @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { handleRequest(error: any, user: any, info: any) { if (error || !user) { throw new UnauthorizedException('验证失败'); } return user; } }
|
现在,我们重新实现了这个守卫,提供了更严格的验证和更好的错误处理:
jwt-auth.guard.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { TokenUtils } from '../utils/token.utils'; import winstonLogger from '../../loggers/winston.logger';
@Injectable() export class JwtAuthGuard implements CanActivate { private logger = winstonLogger;
constructor( private jwtService: JwtService, private configService: ConfigService ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { const token = TokenUtils.extractTokenFromContext(context);
if (!token) { this.logger.warn(`未提供访问令牌:${context}`); throw new UnauthorizedException('未提供访问令牌'); }
try { const request = context.switchToHttp().getRequest(); request.user = await this.jwtService.verifyAsync(token, { audience: this.configService.get<string>('JWT_TOKEN_AUDIENCE'), issuer: this.configService.get<string>('JWT_TOKEN_ISSUER') }); return true; } catch (error) { this.logger.error(`验证访问令牌失败:${error.message}`, error.stack); throw new UnauthorizedException('无效的访问令牌'); } } }
|
-
刷新令牌守卫
新增 JwtRefreshGuard
专门用于处理刷新令牌的验证:
jwt-refresh.guard.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { TokenUtils } from '../utils/token.utils'; import { getJWTVerifyOptions } from '../../config/jwt.config'; import winstonLogger from '../../loggers/winston.logger';
@Injectable() export class JwtRefreshGuard { private logger = winstonLogger;
constructor( private jwtService: JwtService, private configService: ConfigService ) {}
async canActivate(context: ExecutionContext) { const token = TokenUtils.extractTokenFromContext(context); const options = getJWTVerifyOptions(this.configService).refreshToken;
try { this.jwtService.verify(token, options); return true; } catch (error) { this.logger.error(`验证刷新令牌失败:${error.message}`, error.stack); throw new UnauthorizedException('无效的刷新令牌'); } } }
|
4.3.6. 工具类支持
在之前的实现中,令牌提取的逻辑分散在各处,容易导致处理不一致。为了统一处理令牌的提取逻辑,新增 TokenUtils
工具类:
token.utils.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import { ExecutionContext } from '@nestjs/common'; import { Request } from 'express';
export class TokenUtils { static extractToken(request: Request): string | undefined { const [type, token] = request.headers.authorization?.split(' ') ?? []; return type === 'Bearer' ? token : undefined; }
static extractTokenFromContext(context: ExecutionContext): string | undefined { const request = context.switchToHttp().getRequest<Request>(); return this.extractToken(request); } }
|
4.4. 客户端功能实现
4.4.1. API 请求封装
在用户登录后,我们需要确保所有 API 请求都能正确携带身份验证信息。然而,accessToken
可能会过期,如果没有自动刷新机制,用户将频繁被登出,影响体验。我们希望实现一个自动处理令牌的方案,使得:
- 每个请求都自动携带
accessToken
进行身份验证。
- 当
accessToken
过期时,自动使用 refreshToken
获取新的 accessToken
,并重试原请求。
- 如果
refreshToken
也失效,则清除令牌并让用户重新登录。
我们使用 Axios 拦截器 来实现这一机制,主要分为两部分:
-
请求拦截器:
- 在每个请求发送前,自动读取
accessToken
,并将其添加到请求头。
- 如果
accessToken
不存在,则直接发送请求。
-
响应拦截器:
- 监听 API 响应,如果返回
401 Unauthorized
,说明 accessToken
可能已过期。
- 如果
refreshToken
仍然有效,则使用它请求新的 accessToken
,然后重试原始请求。
- 如果
refreshToken
失效,则清除所有令牌,并要求用户重新登录。
这是客户端请求与响应拦截逻辑的流程:
api.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| const api = axios.create({ baseURL: process.env.REACT_APP_API_URL, withCredentials: true, timeout: 10000 });
api.interceptors.request.use( (config) => { const token = localStorage.getItem('accessToken'); if (token) { config.headers = AxiosHeaders.from(config.headers); config.headers.set('Authorization', `Bearer ${token}`); } return config; }, (error) => Promise.reject(error) );
api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; const refreshToken = localStorage.getItem('refreshToken'); if (refreshToken) { try { const { data } = await axios.post( `${process.env.REACT_APP_API_URL}/auth/refresh`, { refreshToken } ); const newAccessToken = data.access_token; localStorage.setItem('accessToken', newAccessToken); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; return api(originalRequest); } catch (error) { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); return Promise.reject(error); } } } return Promise.reject(error); } );
|
这个改造实现了对身份验证流程的优化和自动化:
- 能够自动为请求添加访问令牌,确保每次请求都具备正确的身份验证信息。
- 能够检测令牌是否过期,并在必要时进行处理,防止因过期导致请求失败。
- 支持自动刷新令牌,并在刷新成功后重新尝试原始请求,从而提高用户体验,减少因身份验证失效带来的干扰。
4.4.2. 状态管理优化
首先先介绍一下身份验证流程中整个认证状态的流转:
- 用户在首次访问时可能处于 未认证 状态,系统会自动检查用户的登录状态。
- 认证过程中可能会发生令牌过期的情况,需要进行 令牌刷新。
- 而在用户注销时,系统会清除本地存储数据。
为了有效管理用户的认证状态和流程,我们引入了一个状态机,覆盖以下几种主要状态和转变:
-
未认证
初始状态,表示用户尚未通过认证。系统会自动检查是否有有效的认证信息(如 accessToken
或 refreshToken
)。如果没有,用户将无法访问受保护的资源。
-
认证中
系统正在验证用户的认证信息,可能是通过自动登录检查(例如检查本地存储的 accessToken
或 refreshToken
)来恢复用户的会话。若检查成功,用户进入 已认证 状态;若失败,进入 未认证 状态。
-
已认证
认证成功后,用户的身份验证信息有效,允许访问受保护的资源。如果在该状态下,用户的 accessToken
过期,系统会自动尝试通过刷新令牌(refreshToken
)来更新 accessToken
,进入 令牌刷新中 状态。
-
令牌刷新中
当 accessToken
过期且用户依然处于已认证状态时,系统会通过 refreshToken
尝试获取新的 accessToken
。如果刷新成功,系统返回 已认证 状态;如果刷新失败,用户会被迫重新认证(进入 未认证 状态)。
-
已注销
用户主动退出时,系统会清除本地存储的认证信息,返回到 未认证 状态,确保用户的会话被完全销毁。
-
状态转换
通过自动登录检查、令牌刷新等机制,我们确保用户认证状态的持续性和稳定性。系统根据认证状态的变化自动调整访问权限和用户会话管理。
在用户状态管理中,我们先添加了自动登录相关的功能:
actions.tsts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| const createUserSlice: StateCreator<UserState> = (set, get) => ({ isAutoLoading: false,
clearUser: () => { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); set({ user: null, isAutoLoading: false }); },
autoLogin: async () => { const state = get(); if (state.isAutoLoading || state.user) return;
const accessToken = localStorage.getItem('accessToken'); const refreshToken = localStorage.getItem('refreshToken');
if (!accessToken && !refreshToken) return;
set({ isAutoLoading: true });
try { let token = accessToken;
if (!token && refreshToken) { const response = await api.post('/auth/refresh', { refreshToken }); token = response.data.access_token; if (token) { localStorage.setItem('accessToken', token); } }
const response = await api.get('/auth/me', { headers: { Authorization: `Bearer ${token}` } });
set({ user: response.data, isAutoLoading: false }); } catch (error) { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); set({ user: null, isAutoLoading: false }); throw error; } },
logout: async () => { try { await api.post('/auth/logout'); } finally { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); set({ user: null }); } } });
|
4.4.3. 状态管理范式转变
在登录功能中,我们需要处理用户的身份验证,同时提供“记住我”功能。然而,使用传统的状态管理(也就是我们的 Zustand)来存储 error
和 isLoading
状态可能会带来一些问题:
- 全局状态污染:
error
和 isLoading
这些状态只是登录相关,不需要在整个应用范围内存储。
- 手动管理状态:需要在
try/catch
中手动更新 isLoading
,并在发生错误时手动存储 error
,代码较为冗长。
- 状态同步问题:如果多个组件依赖相同的登录逻辑,手动管理状态可能会导致数据不同步。
为了解决这些问题,我们决定使用 React Query 提供的 useMutation
,将登录请求的状态管理完全交由 React Query 处理。
安装 React Query:
bash
1
| yarn add @tanstack/react-query
|
让我们以注册组件为例,来看看这次改造。
在之前的实现中,我们使用全局状态来管理加载状态和错误信息:
Register.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| const Register = () => { const register = useUserStore(state => state.register); const isLoading = useUserStore(state => state.isLoading); const error = useUserStore(state => state.error);
const formik = useFormik({ onSubmit: async (values) => { try { const { username, email, password } = values; await register({ username, email, password }); navigate('/login', { state: { message: '注册成功!请登录您的账号。', email: values.email } }); } catch (error) { console.error('注册失败:', error); } } }); };
|
这种方式存在以下问题:
- 状态管理过于集中,不同组件的加载状态和错误状态混杂在一起
- 需要手动管理状态的清理和重置
- 错误处理不够优雅
- 缺乏对请求生命周期的完整控制
在开始实现具体功能之前,我们需要先配置 React Query。首先修改应用的入口文件 index.tsx
:
index.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( <React.StrictMode> <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> </React.StrictMode> );
|
使用 React Query 的 useMutation
后,代码变得更加清晰和强大:
Register.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| const Register = () => { const setUser = useUserStore(state => state.setUser);
const { mutate: register, isPending, error } = useMutation({ mutationFn: async (values: { username: string; email: string; password: string; }) => { const response = await api.post('/users/create', values); return response.data; }, onSuccess: (data) => { setUser(data.user); navigate('/login', { state: { message: '注册成功!请检查邮箱完成验证', email: data.user.email }, replace: true }); } });
const formik = useFormik({ onSubmit: async (values) => { register({ username: values.username, email: values.email, password: values.password }); } }); };
|
这种改进带来了多个显著的好处,使得代码更加清晰、组件更加独立,且状态管理更加高效:
-
实现了状态隔离。每个组件都有自己独立的加载和错误状态,不同的请求不会相互影响,从而避免了状态混乱的问题。同时,当组件卸载时,状态会自动清理,确保资源的合理释放。
-
得益于生命周期钩子的引入,异步操作的管理更加灵活。mutationFn
负责定义实际的异步操作,onSuccess
处理成功场景,而 onError
则用于错误处理。这些钩子让我们能够更精准地控制请求的执行过程。
在错误处理方面,这种改进也带来了增强。错误信息会被自动捕获并保存,同时错误状态与组件绑定,使得错误管理更加直观。此外,还能够提供更详细的错误类型信息,方便开发者调试和优化。
-
大幅简化了使用方式。开发者不需要手动处理 try/catch
逻辑,也不需要手动管理 loading
状态,只需直接调用 mutate
函数,即可完成异步操作,提高了开发效率和代码可读性。
使用 useMutation
的基本步骤:
- 定义
mutation
函数:
ts
1 2 3 4
| mutationFn: async (values) => { const response = await api.post('/users/create', values); return response.data; }
|
- 配置生命周期钩子:
ts
1 2 3 4 5 6
| onSuccess: (data) => { }, onError: (error) => { }
|
- 在组件中使用:
ts
1 2 3 4 5 6 7 8
| const { mutate, isPending, error } = useMutation({ ... });
mutate(values);
{isPending && <LoadingSpinner />} {error && <ErrorMessage error={error} />}
|
同样的,修改 VerifyEmail
组件:
VerifyEmail.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129
| import React, {useEffect} from 'react'; import {useLocation, useNavigate} from 'react-router-dom'; import {useMutation} from '@tanstack/react-query'; import {useUserStore} from '../stores'; import api from '../stores/common/api'; const VerifyEmail = () => { const location = useLocation(); const navigate = useNavigate(); const token = new URLSearchParams(location.search).get('token'); const { emailVerified, verificationError, verificationToken, verificationUserId, clearVerificationState } = useUserStore(state => ({ emailVerified: state.emailVerified, verificationError: state.verificationError, verificationToken: state.verificationToken, verificationUserId: state.verificationUserId, clearVerificationState: state.clearVerificationState })); const { mutate: verifyEmail, isPending } = useMutation({ mutationFn: async (token: string) => { const response = await api.get(`/users/verify-email?token=${token}`); return response.data; }, onSuccess: (data) => { if (data.success) { useUserStore.setState({ emailVerified: true, verificationUserId: data.userId }); navigate('/login', { replace: true }) } else { useUserStore.setState({ verificationError: data.error, verificationUserId: data.userId }); } }, onError: (error) => { useUserStore.setState({ verificationError: error.message || '验证过程中发生未知错误' }); } }); useEffect(() => { if (token) verifyEmail(token); }, [token]); useEffect(() => { return () => { clearVerificationState(); window.history.replaceState({}, '', window.location.pathname); } }, []); const handleResend = async () => { if (verificationUserId) { try { await api.post(`/users/resend-verification/${verificationUserId}`); navigate('/login', { state: { message: '新的验证邮件已发送,请查收邮箱' } }); } catch (error) { useUserStore.setState({ verificationError: '重新发送验证邮件失败' }); throw error instanceof Error ? error.message : '重新发送验证邮件失败'; } } } return ( <div className="min-h-screen flex items-center justify-center bg-base-200"> <div className="card w-96 bg-base-100 shadow-xl"> <div className="card-body items-center text-center"> <h2 className="card-title mb-4"> 验证电子邮件 </h2> {isPending && ( <div className="flex flex-col items-center gap-4"> <span className="loading loading-spinner loading-lg" /> <p>正在验证您的邮箱...</p> </div> )} {emailVerified && ( <div className="alert alert-success"> <span>邮箱验证成功!正在跳转至登录页面……</span> </div> )} {verificationError && ( <div className="alert alert-error flex flex-col gap-3"> <span>{verificationError}</span> {verificationUserId && ( <button className="btn btn-sm btn-outline" onClick={handleResend} > 重新发送验证邮件 </button> )} </div> )} {!isPending && !emailVerified && !verificationError && verificationToken && ( <div className="flex flex-col gap-3"> <p>验证链接已失效</p> <button className="btn btn-outline" onClick={() => verifyEmail(verificationToken)} > 重新尝试验证 </button> </div> )} </div> </div> </div> ); }; export default VerifyEmail;
|
4.4.4. 登录组件改造
我们需要对登录组件(Login.tsx
)进行改造,以支持“记住我”功能。
-
在表单验证模式中增加“记住我”选项:
Login.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| const validationSchema = Yup.object().shape({ username: Yup.string().required('请输入用户名!'), password: Yup.string().required('请输入密码!'), rememberMe: Yup.boolean() });
const formik = useFormik({ initialValues: { username: '', password: '', rememberMe: false }, validationSchema, onSubmit: async (values) => { login(values); } });
|
-
使用 useMutation
重构登录逻辑:
Login.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| const { mutate: login, isPending, error } = useMutation({ mutationFn: async (credentials: { username: string, password: string, rememberMe: boolean, }) => { const response = await api.post('/auth/login', credentials); return response.data; }, onSuccess: async (data) => { localStorage.setItem('accessToken', data.access_token); if (formik.values.rememberMe) { localStorage.setItem('refreshToken', data.refresh_token); } setUser(data.user); navigate('/', { state: { message: '登陆成功!' }, replace: true }) }, onError: (error) => { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); clearUser(); console.log('登录失败:', error); } });
|
-
添加自动登录检查逻辑:
Login.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| useEffect(() => { const checkAutoLogin = async () => { const accessToken = localStorage.getItem('accessToken'); const refreshToken = localStorage.getItem('refreshToken');
if (accessToken || refreshToken) { try { await autoLogin(); } catch (error) { clearUser(); localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); console.log('检查自动登录失败:', error); } } }
checkAutoLogin().then(r => console.log('检查自动登录成功:', r)); }, []);
|
-
登录后自动跳转:
Login.tsxtsx
1 2 3 4 5
| useEffect(() => { if (user && !isAutoLoading) { navigate('/', { replace: true }); } }, [user, isAutoLoading, navigate]);
|
-
添加“记住我”选项的界面元素:
Login.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <div className="mb-4 flex items-center"> <input type="checkbox" id="rememberMe" name="rememberMe" className="w-4 h-4 text-primary bg-base-300 border-neutral-600 rounded" checked={formik.values.rememberMe} onChange={formik.handleChange} disabled={isPending} /> <label htmlFor="rememberMe" className="ml-2 text-sm text-neutral-content cursor-pointer" > 记住我 </label> </div>
|
4.5. 邮箱验证提示
为了提升用户体验,我们需要在用户未验证邮箱时给出明显的提示。这个功能包含两个部分:提示组件和布局改造。
创建 UnverifiedBanner
组件来显示验证提示:
UnverifiedBanner.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import React from 'react'; import { useUserStore } from '../stores';
const UnverifiedBanner = () => { const user = useUserStore(state => state.user); if (user && !user.verified) { return ( <div className="p-4 mb-4 text-sm text-warning-content bg-warning rounded"> 您的邮箱尚未验证,请尽快验证。您可以修改邮箱地址或<a href="/resend-verification" className="underline ml-1">重新发送验证邮件</a>。 </div> ); } return null; };
export default UnverifiedBanner;
|
这个组件具有几个重要特点。它通过全局状态获取用户信息,确保始终使用最新的用户数据。同时,它只会在用户尚未完成验证时显示,避免对已验证用户造成干扰。此外,组件还提供了快捷的邮箱验证操作,使用户能够方便地完成身份确认,提高使用体验。
将验证提示集成到 AuthLayout
中:
AuthLayout.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import React from 'react'; import UnverifiedBanner from '../components/UnverifiedBanner'; type AuthLayoutProps = { children: React.ReactNode; }; const AuthLayout = ({ children }: AuthLayoutProps) => { return ( <div className="flex flex-col min-h-screen items-center justify-center bg-base-200"> <UnverifiedBanner /> <div className="w-full max-w-md bg-base-100 p-8 rounded-lg shadow-xl"> {children} </div> </div> ); }; export default AuthLayout;
|
这种改进带来了多方面的好处。用户可以及时了解自己的邮箱验证状态,避免因未验证而影响正常使用。同时,组件提供了直接的验证操作入口,使用户能够快速完成身份确认。此外,它保持了统一的视觉风格,与整体界面设计相协调,并且不会影响原有的布局结构,确保页面的整洁与一致性。
为了解决用户可能未收到验证邮件或验证邮件过期的问题,我们需要实现验证邮件重发功能:
ResendVerification.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
| import React, {useCallback, useEffect} from 'react'; import { useNavigate } from 'react-router-dom'; import {useMutation} from '@tanstack/react-query'; import { useUserStore } from '../stores'; import api from '../stores/common/api';
const ResendVerification = () => { const navigate = useNavigate();
const { user, verificationUserId, clearVerificationState } = useUserStore(state => ({ user: state.user, verificationUserId: state.verificationUserId, clearVerificationState: state.clearVerificationState }));
const { mutate: resendEmail, isPending, error } = useMutation({ mutationFn: async (userId: string) => { const response = await api.post(`/users/resend-verification/${userId}`); return response.data; }, onSuccess: () => { navigate('/login', { state: { message: '新的验证邮件已发送至你的注册邮箱' }, replace: true }); } })
const handleResend = useCallback(() => { if (!verificationUserId) { console.error('缺少用户 ID'); return; } resendEmail(verificationUserId); }, [verificationUserId, resendEmail]);
useEffect(() => { return () => { clearVerificationState(); }; }, [clearVerificationState]);
return ( <div className="min-h-screen flex items-center justify-center bg-base-200"> <div className="card w-96 bg-base-100 shadow-xl"> <div className="card-body items-center text-center"> <h2 className="card-title mb-4"> 邮箱验证 </h2>
<div className="flex flex-col items-center gap-4"> <p className="text-sm"> 你的邮箱 <strong className="text-primary">{user?.email}</strong> 还未验证 </p>
{error && ( <div className="alert alert-error shadow-lg"> <div> <svg xmlns="http://www.w3.org/2000/svg" className="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> <span>{error.message}</span> </div> </div> )}
<button onClick={handleResend} disabled={isPending} className={`btn btn-primary w-full ${isPending ? 'loading' : ''}`} > {isPending ? '发送中...' : '重新发送验证邮件'} </button>
<div className="text-sm mt-4"> <p className="text-gray-500"> 没有收到邮件?请检查垃圾邮件文件夹 </p> <p className="text-gray-500 mt-2"> 需要修改邮箱?前往{' '} <a href="/settings" className="link link-primary" onClick={(e) => { e.preventDefault(); navigate('/settings'); }} > 账户设置 </a> </p> </div> </div> </div> </div> </div> ); }
export default ResendVerification;
|
通过这个功能,我们为用户提供了一个完整的邮箱验证补救方案,帮助他们顺利完成账号验证过程。
4.6. 错误边界处理
在复杂的单页应用中,错误处理是一个非常重要的环节。为了防止应用因为某个组件的错误而完全崩溃,我们引入了错误边界(Error Boundary)机制。
安装 react-error-boundary
:
bash
1
| yarn add react-error-boundary
|
4.6.1. 应用入口改造
首先,我们在应用的最顶层添加错误边界保护。修改 App.tsx
:
App.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import React from 'react'; import { RouterProvider } from 'react-router-dom'; import { ErrorBoundary } from 'react-error-boundary'; import router from './router'; import ErrorFallback from './components/ErrorFallback';
const App = () => { return ( <ErrorBoundary FallbackComponent={ErrorFallback}> <RouterProvider router={ router } /> </ErrorBoundary> ); }
export default App;
|
这样做可以捕获整个应用中的 React 组件错误,防止应用崩溃。
4.6.2. 错误回退组件
我们创建了一个专门的错误回退组件 ErrorFallback.tsx
,用于显示错误信息并提供重试功能:
ErrorFallback.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import React from 'react'; import { FallbackProps } from 'react-error-boundary';
const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => { return ( <div className="alert alert-error shadow-lg"> <div> <svg xmlns="http://www.w3.org/2000/svg" className="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> <div> <h3 className="font-bold">发生错误!</h3> <pre className="whitespace-pre-wrap">{error.message}</pre> </div> </div> <button className="btn btn-sm btn-primary" onClick={resetErrorBoundary} > 重试 </button> </div> ); }
export default ErrorFallback;
|
错误回退组件具备以下核心功能,旨在提升错误处理的可读性和用户体验:
- 提供清晰的错误信息展示。组件能够显示友好的错误提示,同时呈现具体的错误详情,并保持错误信息的格式化显示,以便用户理解问题所在。
- 支持错误恢复功能。用户可以通过重试按钮尝试重新执行操作,同时,
resetErrorBoundary
方法允许重置错误状态,使应用能够正常运行,让用户得以从错误中恢复。
- 在用户体验方面,组件采用统一的错误提示样式,确保与应用的整体设计风格保持一致。同时,它提供清晰的操作指引,帮助用户快速做出合适的应对操作,从而减少因错误导致的使用困扰。
4.7. 路由访问控制
为了确保用户只能访问其权限内的页面,我们需要实现路由保护机制。这包括对私有路由的保护和对公共路由的控制。
4.7.1. 受保护路由组件
创建 ProtectedRoute
组件用于保护需要登录才能访问的页面:
ProtectedRoute.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import React from 'react'; import {useLocation, Navigate} from 'react-router-dom'; import {useUserStore} from '../stores';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const user = useUserStore(state => state.user); const isAutoLoading = useUserStore(state => state.isAutoLoading); const location = useLocation();
if (isAutoLoading) return <div>加载中……</div>;
if (!user) return <Navigate to="/login" state={{from: location}} replace />;
return <>{children}</>; }
export default ProtectedRoute;
|
4.7.2. 公共路由组件
创建 PublicRoute
组件用于处理登录、注册等公共页面:
PublicRoute.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import React from 'react'; import {useUserStore} from '../stores'; import {Navigate, useLocation} from 'react-router-dom';
const PublicRoute = ({children} : {children: React.ReactNode}) => { const user = useUserStore(state => state.user); const isAutoLoading = useUserStore(state => state.isAutoLoading); const location = useLocation();
if (isAutoLoading) return <div>加载中……</div>;
if (user) { const from = location.state?.from?.pathname || '/'; return <Navigate to={from} replace />; }
return <>{children}</>; }
export default PublicRoute;
|
4.7.3. 路由配置优化
使用这些保护组件来包装路由:
router.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const router = createBrowserRouter([ { path: '/', element: <ProtectedRoute><MainPage /></ProtectedRoute> }, { path: '/register', element: <PublicRoute><Register /></PublicRoute> }, { path: '/login', element: <PublicRoute><Login /></PublicRoute> }, { path: '/verify-email', element: <VerifyEmail /> }, { path: '/resend-verification', element: <ResendVerification /> } ]);
|
可以写一个简单的 MainPage
组件来测试登出功能:
MainPage.tsxtsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import React from 'react'; import { useUserStore } from '../stores'; import MainLayout from '../layouts/MainLayout';
const MainPage = () => { const logout = useUserStore(state => state.logout);
return ( <MainLayout> <button onClick={logout}>登出</button> </MainLayout> ) };
export default MainPage;
|