在构建电商平台的过程中,用户登录是一个不可或缺的核心功能。

本文将详细介绍如何在 React 前端实现登录表单组件,并结合 NestJS 后端完成完整的用户认证流程,包括 JWT 认证、记住我功能以及登录状态持久化等关键特性。

1. 小改动

1.1. 改变量/字段名字

将 React 项目的 src/stores/user/types.ts 中的 LoginCredentialsemail 修改为 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; // 修正为 string 类型,因为使用 UUID
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. 实现登录表单组件

在电商平台中,我们需要一个用户友好的登录界面,让用户能够:

  • 输入用户名和密码进行登录
  • 在输入过程中获得适当的表单验证反馈
  • 看到登录状态的加载提示
  • 收到登陆成功或者失败的明确提示
  1. 让我们先在 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;
  2. 使用 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);
    }
    }
    });
  3. 使用 Yup 进行表单验证,确保用户名和密码不为空:

    Login.tsxtsx
    1
    2
    3
    4
    const validationSchema = Yup.object().shape({
    username: Yup.string().required('请输入用户名!'),
    password: Yup.string().required('请输入密码!')
    });
  4. 以下是页面的基本结构:

    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>
    )
  5. 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 认证机制,我们需要从多个方面入手:

  1. 配置认证模块、JWT 模块的签名密钥以及相关选项
  2. 集成用户模块,以便进行用户身份验证
  3. 实现认证服务,该服务需要负责验证用户凭据、生成 JWT 令牌、处理令牌刷新以及检查令牌的有效性,确保认证流程的完整性和安全性
  4. 创建一个认证控制器,专门处理用户的登录请求,并返回认证结果和 JWT 令牌,确保前端能够正确接收和使用身份验证信息
  5. 实现 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
) {}

// ...
}
  1. 验证用户:

    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;
    }
  2. 用户登录:

    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
    1
    头.载荷.签名
    1. 头包含了令牌类型和使用的签名算法:

      json
      1
      2
      3
      4
      {
      "alg": "HS256", // 使用的算法
      "typ": "JWT"
      }
    2. 载荷包含了我们存储的实际数据:

      json
      1
      2
      3
      4
      5
      6
      7
      8
      {
      "sub": "1234567890", // 用户 ID
      "username": "john_doe", // 用户名
      "email": "[email protected]", // 邮箱
      "role": "user", // 角色
      "iat": 1516239022, // 签发时间
      "exp": 1516242622 // 过期时间
      }
    3. 签名则使用密钥、对头和载荷进行签名:

      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 的标准组成部分。

  3. 在令牌过期前更新令牌:

    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)
    };
    }

    当现有访问令牌即将过期时,服务器会生成一个新的访问令牌。

    使用与原始登录相同的用户信息构建新的载荷,这样用户就不需要重新登陆,可以无缝继续使用系统。

  4. 验证令牌:

    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。

  1. 定义登录请求的数据结构:

    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;
    }
  2. 登录响应 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;
}
}

守卫的工作流程:

  1. 收到请求时,检查 Authorization 头部是否包含 JWT 令牌
  2. 如果没有令牌或令牌无效,直接拦截请求并返回 401 错误
  3. 如果令牌有效,让请求通过并继续处理

使用方式也很简单:

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) {}

// 无需登录即可访问的公开 API
// 例如获取热门商品
@Get('popular')
getPopularOrders() {
return this.ordersService.getPopularOrders();
}

// 需要登录才能访问的 API
// 例如用户的订单
@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) {
// user 对象包含了 JWT 令牌中的用户信息
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; // 从 request 对象中手动获取用户信息
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,确保账户安全
有效
无效
用户登录
记住我?
生成7天RefreshToken
生成会话级RefreshToken
返回AccessToken+RefreshToken
访问API携带AccessToken
Token有效?
正常访问
使用RefreshToken获取新AccessToken
重新尝试API请求

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 配置
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. 数据结构调整

  1. Users 实体中添加 refreshToken 字段,用于存储刷新令牌:

    users.entity.tsts
    1
    2
    @Column({ nullable: true })
    refreshToken?: string;
  2. LoginDto 中添加“记住我”选项:

    login.dto.tsts
    1
    2
    3
    4
    @ApiProperty({ example: false, required: false, description: '是否记住我' })
    @IsBoolean()
    @IsOptional()
    rememberMe?: boolean;
  3. LoginResponseDto 中新增刷新令牌字段:

    login-response.dto.tsts
    1
    2
    @ApiProperty({ description: '刷新令牌' })
    refresh_token: string;

4.3.3. 认证服务实现

AuthService 中,我们需要进行以下主要改动:

  1. 为了支持“记住我”功能,需要在用户登录时生成两种令牌:访问令牌(accessToken)和刷新令牌(refreshToken)。

    1. 将原有的令牌生成逻辑修改为:

      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
      );
    2. 在登录响应中添加刷新令牌:

      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: { ... }
      };
  2. 新增令牌刷新功能,用于在访问令牌过期后获取新的访问令牌:

    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')
    };
    }
  3. 添加登出功能,用于清除用户的刷新令牌:

    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. 守卫机制完善

  1. 访问令牌守卫

    原来的 JwtAuthGuard 实现存在几个问题:

    • 没有直接验证 token 的有效性
    • 没有正确处理 audienceissuer 的验证

    看一眼原本的代码:

    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('无效的访问令牌');
    }
    }
    }
  2. 刷新令牌守卫

    新增 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 拦截器 来实现这一机制,主要分为两部分:

  1. 请求拦截器:

    • 在每个请求发送前,自动读取 accessToken,并将其添加到请求头。
    • 如果 accessToken 不存在,则直接发送请求。
  2. 响应拦截器:

    • 监听 API 响应,如果返回 401 Unauthorized,说明 accessToken 可能已过期。
    • 如果 refreshToken 仍然有效,则使用它请求新的 accessToken,然后重试原始请求。
    • 如果 refreshToken 失效,则清除所有令牌,并要求用户重新登录。

这是客户端请求与响应拦截逻辑的流程:

请求拦截
存在accessToken?
发起请求
添加Authorization头
直接发送
响应拦截
状态码=401?
接收响应
有refreshToken?
发起刷新请求
刷新成功?
更新accessToken并重试
跳转登录页
正常处理
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;

// 处理 401 错误和令牌刷新
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);
}
);

这个改造实现了对身份验证流程的优化和自动化:

  1. 能够自动为请求添加访问令牌,确保每次请求都具备正确的身份验证信息。
  2. 能够检测令牌是否过期,并在必要时进行处理,防止因过期导致请求失败。
  3. 支持自动刷新令牌,并在刷新成功后重新尝试原始请求,从而提高用户体验,减少因身份验证失效带来的干扰。

4.4.2. 状态管理优化

首先先介绍一下身份验证流程中整个认证状态的流转:

自动登录检查
自动登录成功
自动登录失败
用户主动登出
AccessToken过期
刷新成功
刷新失败
清除本地存储
未认证
认证中
已认证
已注销
令牌刷新中
  1. 用户在首次访问时可能处于 未认证 状态,系统会自动检查用户的登录状态。
  2. 认证过程中可能会发生令牌过期的情况,需要进行 令牌刷新
  3. 而在用户注销时,系统会清除本地存储数据。

为了有效管理用户的认证状态和流程,我们引入了一个状态机,覆盖以下几种主要状态和转变:

  1. 未认证

    初始状态,表示用户尚未通过认证。系统会自动检查是否有有效的认证信息(如 accessTokenrefreshToken)。如果没有,用户将无法访问受保护的资源。

  2. 认证中

    系统正在验证用户的认证信息,可能是通过自动登录检查(例如检查本地存储的 accessTokenrefreshToken)来恢复用户的会话。若检查成功,用户进入 已认证 状态;若失败,进入 未认证 状态。

  3. 已认证

    认证成功后,用户的身份验证信息有效,允许访问受保护的资源。如果在该状态下,用户的 accessToken 过期,系统会自动尝试通过刷新令牌(refreshToken)来更新 accessToken,进入 令牌刷新中 状态。

  4. 令牌刷新中

    accessToken 过期且用户依然处于已认证状态时,系统会通过 refreshToken 尝试获取新的 accessToken。如果刷新成功,系统返回 已认证 状态;如果刷新失败,用户会被迫重新认证(进入 未认证 状态)。

  5. 已注销

    用户主动退出时,系统会清除本地存储的认证信息,返回到 未认证 状态,确保用户的会话被完全销毁。

  6. 状态转换

    通过自动登录检查、令牌刷新等机制,我们确保用户认证状态的持续性和稳定性。系统根据认证状态的变化自动调整访问权限和用户会话管理。

在用户状态管理中,我们先添加了自动登录相关的功能:

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)来存储 errorisLoading 状态可能会带来一些问题:

  • 全局状态污染:errorisLoading 这些状态只是登录相关,不需要在整个应用范围内存储。
  • 手动管理状态:需要在 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
});
}
});
};

这种改进带来了多个显著的好处,使得代码更加清晰、组件更加独立,且状态管理更加高效:

  1. 实现了状态隔离。每个组件都有自己独立的加载和错误状态,不同的请求不会相互影响,从而避免了状态混乱的问题。同时,当组件卸载时,状态会自动清理,确保资源的合理释放。

  2. 得益于生命周期钩子的引入,异步操作的管理更加灵活。mutationFn 负责定义实际的异步操作,onSuccess 处理成功场景,而 onError 则用于错误处理。这些钩子让我们能够更精准地控制请求的执行过程。

    在错误处理方面,这种改进也带来了增强。错误信息会被自动捕获并保存,同时错误状态与组件绑定,使得错误管理更加直观。此外,还能够提供更详细的错误类型信息,方便开发者调试和优化。

  3. 大幅简化了使用方式。开发者不需要手动处理 try/catch 逻辑,也不需要手动管理 loading 状态,只需直接调用 mutate 函数,即可完成异步操作,提高了开发效率和代码可读性。

使用 useMutation 的基本步骤:

  1. 定义 mutation 函数:
ts
1
2
3
4
mutationFn: async (values) => {
const response = await api.post('/users/create', values);
return response.data;
}
  1. 配置生命周期钩子:
ts
1
2
3
4
5
6
onSuccess: (data) => {
// 处理成功场景
},
onError: (error) => {
// 处理错误场景
}
  1. 在组件中使用:
ts
1
2
3
4
5
6
7
8
const { mutate, isPending, error } = useMutation({ ... });

// 触发 mutation
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)进行改造,以支持“记住我”功能。

  1. 在表单验证模式中增加“记住我”选项:

    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);
    }
    });
  2. 使用 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);
    }
    });
  3. 添加自动登录检查逻辑:

    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));
    }, []);
  4. 登录后自动跳转:

    Login.tsxtsx
    1
    2
    3
    4
    5
    useEffect(() => { 
    if (user && !isAutoLoading) {
    navigate('/', { replace: true });
    }
    }, [user, isAutoLoading, navigate]);
  5. 添加“记住我”选项的界面元素:

    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;

错误回退组件具备以下核心功能,旨在提升错误处理的可读性和用户体验:

  1. 提供清晰的错误信息展示。组件能够显示友好的错误提示,同时呈现具体的错误详情,并保持错误信息的格式化显示,以便用户理解问题所在。
  2. 支持错误恢复功能。用户可以通过重试按钮尝试重新执行操作,同时,resetErrorBoundary 方法允许重置错误状态,使应用能够正常运行,让用户得以从错误中恢复。
  3. 在用户体验方面,组件采用统一的错误提示样式,确保与应用的整体设计风格保持一致。同时,它提供清晰的操作指引,帮助用户快速做出合适的应对操作,从而减少因错误导致的使用困扰。

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();

// TODO: 返回一个加载动画
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;