Nest.js最佳实践:实现安全高效的登录认证

1,094 阅读8分钟

Nest.js 登录接口开发指南

随着Web应用的不断发展,安全性和用户体验成为了开发者关注的重点。Nest.js作为一个渐进式Node.js框架,它基于现代JavaScript标准,提供了丰富的功能来构建高效、可扩展的应用程序。本文将指导您如何在Nest.js中开发一个安全的登录接口,包括用户认证、密码校验、JWT生成及验证等关键步骤。

1. 登录接口技术方案设计

在开始编码之前,首先需要明确登录接口的技术方案。通常,登录接口需要完成以下任务:

  • 验证用户提供的用户名和密码是否正确。
  • 生成并返回一个安全的token给客户端。
  • 在后续的API请求中,通过token验证用户身份。

为了实现这些功能,我们将使用以下技术栈:

  • Nest.js 作为后端框架。
  • TypeORM 作为ORM工具,用于数据库操作。
  • JSON Web Tokens (JWT) 用于用户会话管理。

2. NestJs 请求守卫开发

Nest.js 中的守卫(Guards)可以用来控制访问特定路由的权限。在登录接口中,我们需要创建一个守卫来检查请求是否包含有效的token。这可以通过实现CanActivate接口来完成。

import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from './public.decorator';
import { JwtService } from '@nestjs/jwt';
import { JWT_SECRET_KEY } from './auth.jwt.secret';

/**
 * `AuthGuard` 类实现了 NestJS 的 CanActivate 接口,用于控制路由的访问权限。
 * 它通过检查路由是否标记为公共(无需认证)来决定是否允许访问。
 */
@Injectable()
export class AuthGuard implements CanActivate {
  /**
   * 构造函数,注入 Reflector 服务。
   * @param reflector Nest 的反射器,用于获取元数据。
   */
  constructor(
    private reflector: Reflector,
    private jwtService: JwtService,
  ) {}

  /**
   * 判断当前执行上下文是否可以激活,即是否允许访问。
   * @param context 当前的执行上下文,包含请求和响应的信息。
   * @returns 返回一个布尔值、布尔值的 Promise 或 Observable,表示是否允许访问。
   */
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 从当前的执行上下文中获取处理函数和类,检查它们是否被标记为公共。
    const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    // 如果当前路由被标记为公共,则允许访问。
    if (isPublic) {
      return true;
    }

    // 获取当前请求的上下文并提取HTTP请求对象
    const request = context.switchToHttp().getRequest();

    // 从请求中提取令牌
    const token = extractToken(request);

    // 如果没有令牌,则抛出未授权异常
    if (!token) {
      throw new UnauthorizedException();
    }

    try {
      // 验证令牌并获取载荷数据
      const payload = await this.jwtService.verifyAsync(token, {
        secret: JWT_SECRET_KEY,
      });

      // 将验证后的用户信息附加到请求对象中
      request['user'] = payload;
    } catch (error) {
      // 如果验证失败,抛出未授权异常,并包含错误信息
      throw new UnauthorizedException(error.message);
    }

    // 返回true表示验证通过
    return true;
  }
}

/**
 * 从请求中提取认证令牌
 *
 * 此函数旨在处理请求对象,并从中提取出认证令牌它假定认证信息遵循一定的格式,
 * 即以'Bearer'类型后跟实际的令牌值如果请求的认证头不符合预期格式或不存在,
 * 函数将返回undefined
 *
 * @param request 包含认证头的请求对象
 * @returns 如果认证类型为'Bearer',则返回令牌字符串,否则返回undefined
 */
function extractToken(request) {
  // 分割认证头以提取类型和令牌值如果认证头不存在或格式不正确,将得到一个空数组
  const [type, token] = request.headers.authorization?.split(' ') ?? [];

  // 如果认证类型是'Bearer',则返回令牌值;否则,返回undefined
  return type === 'Bearer' ? token : undefined;
}

3. 实现登录鉴权接口的调用链路

登录接口的调用链路通常包括以下几个步骤:

  1. 用户发送包含用户名和密码的POST请求至/auth/login
  2. 后端接收到请求后,使用ORM查询数据库以验证用户信息。
  3. 如果验证成功,生成JWT token,并将其返回给客户端。
  4. 客户端存储此token,并在后续请求中将其作为认证凭据。

4. 登录密码校验的逻辑实现

密码校验是登录过程中非常重要的一步。我们使用md5对密码进行加密。在验证密码时,需要将用户输入的密码与数据库中加密过的密码进行比较。

  // 登录方法,根据用户名和密码进行身份验证
  async login(username, password) {
    // 根据用户名查找用户
    const user = await this.userService.findByUsername(username);
    // 将输入的密码进行MD5加密并转为大写,以匹配存储的密码
    const md5Password = md5(password).toUpperCase();
    // 如果加密后的密码与数据库中的密码不匹配,抛出未授权异常
    if (user.password !== md5Password) {
      throw new UnauthorizedException();
    }

    // 创建JWT令牌的负载信息,包含用户名和用户ID
    const payload = { username: user.username, userid: user.id };
    // 返回包含JWT令牌的对象
    return {
      token: await this.jwtService.signAsync(payload),
    };
  }
}

5. JWT 基本概念

5.1 Token 是什么

Token 本质是字符串,用于请求时附带在请求头中,校验请求是否合法及判断用户身份

5.2 Token 与 Session、Cookie 的区别
  • Session 保存在服务端,用于客户端与服务端连接时,临时保存用户信息,当用户释放连接后,Session 将被释放;
  • Cookie 保存在客户端,当客户端发起请求时,Cookie 会附带在 http header 中,提供给服务端辨识用户身份;
  • Token 请求时提供,用于校验用户是否具备访问接口的权限。
5.3 Token 的用途

Token 的用途主要有三点:

  • 拦截无效请求,降低服务器处理压力;
  • 实现第三方 API 授权,无需每次都输入用户名密码鉴权;
  • 身份校验,防止 CSRF 攻击。
5.6 JWT 简析

JSON Web Token(JWT)是非常流行的跨域身份验证解决方案。

5.7 JWT 构成

下面是一串 JWT 字符串:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.EJzdDeNLOQhy-2WmXuK1B49xF17Tk0pja1tCPp81YjY

1

它将被解析为以下三部分:

5.8 HEADER: ALGORITHM & TOKEN TYPE

JWT 头部分是一个描述 JWT 元数据的 JSON 对象:

  • alg:表示加密算法,HS256 是 HMAC SHA256 的缩写
  • typ:token 类型
{
  "alg": "HS256",
  "typ": "JWT"
}
5.9 PAYLOAD: DATA

JWT 数据部分,payload 是 JWT 的主体内容部分,也是一个 JSON 字符串,包含需要传递的数据,注意 payload 部分不要存储隐私数据,防止信息泄露

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}
5.10 VERIFY SIGNATURE

JWT 签名部分是对上面两部分数据加密后生成的字符串,通过 header 指定的算法生成加密字符串,以确保数据不会被篡改。

生成签名时需要使用密钥(即下方示例中的 abcdefg),密钥只保存在服务端,不能向用户公开,它是一个字符串,我们可以自由指定。

生成签名时需要根据 header 中指定的签名算法,并根据下方的公式,即将 header 和 payload 的数据通过 base64加密后用 . 进行连接,然后通过密钥进行 SHA256 加密,由于加入了密钥,所以生成的字符串将无法被破译和篡改,只有在服务端才能还原

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  abcdefg
)

我们可以在 jwt.io/ 调试 JWT 字符串

6. JWT token 生成逻辑实现

在用户成功登录后,我们需要生成一个JWT token。Nest.js 提供了@nestjs/jwt模块来简化这一过程。

import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(private readonly jwtService: JwtService) {}

  async login(user: any) {
    // 创建JWT令牌的负载信息,包含用户名和用户ID
    const payload = { username: user.username, userid: user.id };
    // 返回包含JWT令牌的对象
    return {
      token: await this.jwtService.signAsync(payload),
    };
  }
}

7. 后端请求守卫 token 验证逻辑

前面提到的AuthGuard类实现了token的基本验证逻辑。每当有受保护的路由被请求时,这个守卫会被自动调用。如果token有效,请求将继续执行;否则,请求将被拒绝。


@Injectable()
export class AuthGuard implements CanActivate {

  constructor(
    private reflector: Reflector,
    private jwtService: JwtService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {

    ...

    // 从请求中提取令牌
    const token = extractToken(request);

    // 如果没有令牌,则抛出未授权异常
    if (!token) {
      throw new UnauthorizedException();
    }

    try {
      // 验证令牌并获取载荷数据
      const payload = await this.jwtService.verifyAsync(token, {
        secret: JWT_SECRET_KEY,
      });

      // 将验证后的用户信息附加到请求对象中
      request['user'] = payload;
    } catch (error) {
      // 如果验证失败,抛出未授权异常,并包含错误信息
      throw new UnauthorizedException(error.message);
    }

    // 返回true表示验证通过
    return true;
  }
}

/**
 * 从请求中提取认证令牌
 *
 * 此函数旨在处理请求对象,并从中提取出认证令牌它假定认证信息遵循一定的格式,
 * 即以'Bearer'类型后跟实际的令牌值如果请求的认证头不符合预期格式或不存在,
 * 函数将返回undefined
 *
 * @param request 包含认证头的请求对象
 * @returns 如果认证类型为'Bearer',则返回令牌字符串,否则返回undefined
 */
function extractToken(request) {
  // 分割认证头以提取类型和令牌值如果认证头不存在或格式不正确,将得到一个空数组
  const [type, token] = request.headers.authorization?.split(' ') ?? [];

  // 如果认证类型是'Bearer',则返回令牌值;否则,返回undefined
  return type === 'Bearer' ? token : undefined;
}

通过上述步骤,我们可以构建一个安全且高效的登录接口。