使用 Express 在 Typescript 中进行 JWT 身份验证

2025-06-07

使用 Express 在 Typescript 中进行 JWT 身份验证

我开始进入 Typescript 身份验证的世界,需要在应用程序的前端和后端对用户进行身份验证。

实现这一目标的神奇秘诀是:

后端部分:

  • 2 个用于用户身份验证的帖子路由(用于注册和登录)
  • 1 个控制器,1 个服务
  • MongoDb 中的 1 个模型、1 个集合
  • 1 个用于散列和比较密码的 bcrypt 包(及其类型)
  • 1 个用于创建和验证令牌的 JWT 包(及其类型)
  • 1 个用于身份验证的中间件

前端部分:

  • 从后端获取令牌并存储
  • 从存储中获取令牌并放入标头中

我们有一个计划,让我们开始我们的旅程吧!

步骤 1. 创建路由、控制器、服务、模型

本项目是按照MVC模式构建的,这样的结构是为了进行逻辑划分。

路线



import * as userController from '../controllers/user.controller';

Router.post('/login', userController.loginOne);
Router.post('/register', userController.registerOne);


Enter fullscreen mode Exit fullscreen mode

控制器



import { Request, Response } from 'express';
import { getErrorMessage } from '../utils/errors.util';
import * as userServices from '../services/user.service';
import { CustomRequest } from '../middleware/auth';

export const loginOne = async (req: Request, res: Response) => {
 try {
   const foundUser = await userServices.login(req.body);
   res.status(200).send(foundUser);
 } catch (error) {
   return res.status(500).send(getErrorMessage(error));
 }
};

export const registerOne = async (req: Request, res: Response) => {
 try {
   await userServices.register(req.body);
   res.status(200).send('Inserted successfully');
 } catch (error) {
   return res.status(500).send(getErrorMessage(error));
 }
};



Enter fullscreen mode Exit fullscreen mode

utils 文件夹中的 getErrorMessage 函数包括:



export function getErrorMessage(error: unknown) {
 if (error instanceof Error) return error.message;
 return String(error);
}


Enter fullscreen mode Exit fullscreen mode

服务



import { DocumentDefinition } from 'mongoose';
import UserModel, { I_UserDocument } from '../models/user.model';

export async function register(user: DocumentDefinition<I_UserDocument>): Promise<void> {
 try {
   await UserModel.create(user);
 } catch (error) {
   throw error;
 }
}

export async function login(user: DocumentDefinition<I_UserDocument>) {
 try {
   const foundUser = await UserModel.findOne({ name: user.name, password: user.password });
 } catch (error) {
   throw error;
 }
}


Enter fullscreen mode Exit fullscreen mode

模型



import mongoose from 'mongoose';

export interface I_UserDocument extends mongoose.Document {
 name: string;
 password: string;
}

const UserSchema: mongoose.Schema<I_UserDocument> = new mongoose.Schema({
 name: { type: String, unique: true },
 password: { type: String },
});

const UserModel = mongoose.model<I_UserDocument>('User', UserSchema);


Enter fullscreen mode Exit fullscreen mode

我没有在接口 I_UserDocument 中包含 _id,因为他扩展了 mongoose.Document 并且已经包含了 _id。

使用 Postman 检查结果。

第 2 步:哈希密码

散列与加密的不同之处在于它是一种单向操作:我们获取密码并对其进行加盐,然后得到一行字母、数字和符号。

关键的区别在于,初始密码无法获取。因此,每次用户设置密码时,都会以相同的方式进行哈希处理,哈希结果也相同。

散列密码示例:$2b$08$LSAG/cRp.tSlvTWzp1pwoe50bDWEDjLfK7Psy5ORzf4C.PxJYZeau

在此步骤中我们牢记两个目标:

  1. 注册后立即对密码进行哈希处理
  2. 登录时检查密码的哈希版本是否与 Mongo 中存储的相同

安装 Bcrypt 及其类型:



npm i bcrypt @types/bcrypt


Enter fullscreen mode Exit fullscreen mode

注册时对密码进行哈希处理

这里我们使用 schema 选项来使用中间件。我们检查密码并将其转换为 bcrypt 和 salt。

明文密码会使用盐(随机字符串)进行哈希处理,其结果不可预测。盐会自动包含在哈希值中,因此您无需将其存储在数据库中。

在这种情况下,数字 8 表示盐轮,建议的最小数量为 8。

模型



import mongoose from 'mongoose';
import bcrypt from 'bcrypt';

const saltRounds = 8

UserSchema.pre('save', async function (next) {
 const user = this;
 if (user.isModified('password')) {
   user.password = await bcrypt.hash(user.password, saltRounds);
 }
 next();
});


Enter fullscreen mode Exit fullscreen mode

由于在模型上使用中间件,我们对密码进行散列并将其存储在数据库中。

比较收到的密码和散列密码

服务



export async function login(user: DocumentDefinition<I_UserDocument>) {
 try {
   const foundUser = await UserModel.findOne({ name: user.name });

   if (!foundUser) {
     throw new Error('Name of user is not correct');
   }

   const isMatch = bcrypt.compareSync(user.password, foundUser.password);

   if (isMatch) {
return foundUser 
   } else {
     throw new Error('Password is not correct');
   }
 } catch (error) {
   throw error;
 }
}


Enter fullscreen mode Exit fullscreen mode

我们按名称搜索用户,如果数据库中存在具有该名称的用户,我们就开始将从用户收到的密码与存储在数据库中的散列密码进行比较:bcrypt.compareSync(password-from-user, password-from-database)
如果 2 个密码相同,我们返回用户。

步骤3.代币实施

我们此步的目标是:

  1. 登录时创建令牌
  2. 登录时验证令牌
  3. 发送 token 到前端

这是什么令牌?

它是一种包含标头、有效载荷和签名的安全手段。

令牌可能是什么样子的?
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.vaYmi2wAFIP-RGn6jvfY_MUYwghZd8rZzeDeZ4xiQmk

点之前的第一部分是包含算法和令牌类型的标头。第二部分是有效负载,其中包含要设置到令牌中的所有数据以及指示令牌过期时间的时间戳。
第三部分是您自己选择的签名。

您可以在https://jwt.io/上检查您的 JWT

解码令牌的示例:
图片描述

重要!我们不需要将 JWT 令牌存储在数据库中。

登录时创建令牌

安装 JWT 及其类型:



npm i jsonwebtoken @types/jsonwebt


Enter fullscreen mode Exit fullscreen mode

服务



if (isMatch) {
     const token = jwt.sign({ _id: foundUser._id?.toString(), name: foundUser.name }, SECRET_KEY, {
       expiresIn: '2 days',
     });

     return { user: { _id, name }, token: token };
   } else {
     throw new Error('Password is not correct');
   }


Enter fullscreen mode Exit fullscreen mode

在 isMatch 条件部分,我创建了一个令牌并将其与用户一起返回。
在令牌的有效负载中,我输入了用户的 ID 和用户名,但没有发送密码。SECRET_KEY 是纯文本,也是我导入的令牌的个人签名。

验证(解码)令牌

为此,我们需要在控制器和服务之间运行中间件。
我在中间件文件夹中创建了 auth.ts 文件。



import jwt, { Secret, JwtPayload } from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';

export const SECRET_KEY: Secret = 'your-secret-key-here';

export interface CustomRequest extends Request {
 token: string | JwtPayload;
}

export const auth = async (req: Request, res: Response, next: NextFunction) => {
 try {
   const token = req.header('Authorization')?.replace('Bearer ', '');

   if (!token) {
     throw new Error();
   }

   const decoded = jwt.verify(token, SECRET_KEY);
   (req as CustomRequest).token = decoded;

   next();
 } catch (err) {
   res.status(401).send('Please authenticate');
 }
};


Enter fullscreen mode Exit fullscreen mode

我们通过从字符串中删除“Bearer”从标题中获取令牌,解码该令牌并将其添加到用户解码(签名)的令牌中。

因此我们回到控制器来运行 LoginOne

控制器



export const loginOne = async (req: Request, res: Response) => {
 try {
   const foundUser = await userServices.login(req.body);
   //console.log('found user', foundUser.token);
   res.status(200).send(foundUser);
 } catch (error) {
   return res.status(500).send(getErrorMessage(error));
 }
};


Enter fullscreen mode Exit fullscreen mode

现在由于中间件,我们不仅可以获得用户,还可以获得 user.token(带有签名的令牌)。

重要!
我们导入了 auth 并将其设置在所有需要进行身份验证的路由上。
有两条路由无法进行身份验证,分别是登录和注册的路由。

另一个需要身份验证的根的示例:



Router.get('/all', auth, searchController.getAll);
Router.post('/', auth, searchController.addOne);
Router.delete('/:id', auth, searchController.deleteOne);


Enter fullscreen mode Exit fullscreen mode

我们已经完成了后端的 JWT 身份验证,现在让我们转到前端。

步骤 4. 移至最前面

我们在前端的步骤:

  1. 从后端获取令牌
  2. 存储令牌
  3. 从存储中提取令牌并将其添加到所选 axios 请求的标头中(不包括注册和签名)
  4. 更改用户界面

我们不会详细介绍所有步骤,我只会给出如何实现的一般描述。

在前端我使用了 React.js 和 axios 包。

使用 axios 请求从后端获取令牌 - 完成:)

存储令牌
存储选项:

  1. 全局状态(Redux、Context)
  2. 曲奇饼
  3. 本地或会话存储

从存储中获取令牌并将其放入标头中

我将令牌存储在 Cookie 中,因此我创建并导入了从 Cookie 获取令牌的函数。I_AuthHeader 是一个自定义接口。



export const authHeader = (): I_AuthHeader => {
 const token = getTokenFromCookies();
 return {
   headers: {
     Authorization: "Bearer " + token,
   },
 };
};



Enter fullscreen mode Exit fullscreen mode

添加标题的示例



import axios from "axios";
let baseUrl = "http://localhost:8080/";
const ApiHeader = axios.create({
 baseURL: baseUrl,
});


Enter fullscreen mode Exit fullscreen mode


export const getSearchWords = async (): Promise<I_Search[]> => {
 try {
   const { data } = await ApiHeader.get("api/search/all", authHeader());
   return data;
 } catch (error) {
   console.error(error);
   throw error;
 }
};


Enter fullscreen mode Exit fullscreen mode

享受改进的 UI!

我很乐意在评论中收到您的反馈:)

文章来源:https://dev.to/juliecherner/authentication-with-jwt-tokens-in-typescript-with-express-3gb1
PREV
CouchDB,开源 Cloud Firestore 的替代品?
NEXT
改进 Typescript 的 7 个技巧