» Node.js:使用Express构建REST API » 2. 开发 » 2.12 权限认证

权限认证

在 API 服务器中想对特定资源或功能的访问进行控制就需要实行身份权限验证

换句话说,如果你想要将某些 API 端点或数据限制给具有特殊角色的用户(例如管理员、版主或高级用户等)使用,你就需要进行身份权限验证。

传统用户流程

更新代码

添加 User 领域实体,domain/model/user.ts

// User represents an app user
export interface User {
  id?: number;
  email: string;
  password: string;
  salt: string;
  is_admin: boolean;
  created_at?: Date;
  updated_at?: Date;
}

声明其业务能力,domain/gateway/user_manager.ts

import { User } from "@/domain/model";

export interface UserManager {
  createUser(u: User): Promise<number>;
  getUserByEmail(email: string): Promise<User | null>;
}

添加其实现,infrastructure/database/mysql.ts

@@ -1,9 +1,9 @@
 import mysql, { ResultSetHeader, RowDataPacket } from "mysql2";
 
-import { Book } from "@/domain/model/book";
-import { BookManager } from "@/domain/gateway/book_manager";
+import { Book, User } from "@/domain/model";
+import { BookManager, UserManager } from "@/domain/gateway";
 
-export class MySQLPersistence implements BookManager {
+export class MySQLPersistence implements BookManager, UserManager {
   private db: mysql.Connection;
   private page_size: number;
 
@@ -84,6 +84,25 @@ export class MySQLPersistence implements BookManager {
     return rows as Book[];
   }
 
+  async createUser(u: User): Promise<number> {
+    const { email, password, salt, is_admin } = u;
+    const [result] = await this.db
+      .promise()
+      .query(
+        "INSERT INTO users (email, password, salt, is_admin, created_at, updated_at) VALUES (?, ?, ?, ?, now(), now())",
+        [email, password, salt, is_admin]
+      );
+    return (result as ResultSetHeader).insertId;
+  }
+
+  async getUserByEmail(email: string): Promise<User | null> {
+    let [rows] = await this.db
+      .promise()
+      .query("SELECT * FROM users WHERE email = ?", [email]);
+    rows = rows as RowDataPacket[];
+    return rows.length ? (rows[0] as User) : null;
+  }
+
   close(): void {
     this.db.end();
   }

实现用户注册和登录功能,application/executor/user_operator.ts

import { createHash } from "crypto";

import { UserManager } from "@/domain/gateway";
import { UserCredential, User } from "@/application/dto";

const saltLen = 4;
const errEmptyEmail = "empty email";
const errEmptyPassword = "empty password";
const errDoesNotExist = "user does not exist";

export class UserOperator {
  private userManager: UserManager;

  constructor(u: UserManager) {
    this.userManager = u;
  }

  async createUser(uc: UserCredential): Promise<User | null> {
    if (!uc.email) {
      throw new Error(errEmptyEmail);
    }
    if (!uc.password) {
      throw new Error(errEmptyPassword);
    }
    const salt = randomString(saltLen);
    const user = {
      email: uc.email,
      password: sha1Hash(uc.password + salt),
      salt,
      is_admin: false,
    };
    const id = await this.userManager.createUser(user);
    return { id, email: uc.email };
  }

  async signIn(email: string, password: string): Promise<User | null> {
    if (!email) {
      throw new Error(errEmptyEmail);
    }
    if (!password) {
      throw new Error(errEmptyPassword);
    }
    const user = await this.userManager.getUserByEmail(email);
    if (!user) {
      throw new Error(errDoesNotExist);
    }
    const passwordHash = sha1Hash(password + user.salt);
    if (user.password !== passwordHash) {
      throw new Error("wrong password");
    }
    return { id: user.id!, email: user.email };
  }
}

function randomString(length: number): string {
  const charset: string =
    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
  let result: string = "";
  for (let i = 0; i < length; i++) {
    result += charset.charAt(Math.floor(Math.random() * charset.length));
  }
  return result;
}

function sha1Hash(input: string): string {
  return createHash("sha1").update(input).digest("hex");
}

函数 randomStringsha1Hash 是用于 salt 生成和密码哈希的辅助函数。

为什么需要盐(salt)?

没有salt,攻击者可以为常见密码预先计算哈希值并将其存储在表中(称为彩虹表)。如果数据库遭到入侵,攻击者可以将哈希密码与预先计算的哈希值进行比较,从而快速识别密码。为每个密码添加 salt 确保即使两个用户使用相同的密码,由于唯一的 salt,它们的哈希密码也会不同。

即使用户选择了弱密码,例如常见的词典单词,salt 也确保了生成的哈希值是唯一的。没有 salt,相同密码的所有实例都会哈希到相同的值,使其易受攻击。

添加 DTO,application/dto/user.ts

// UserCredential represents the user's sign-in email and password
export interface UserCredential {
  email: string;
  password: string;
}

// User is used as result of a successful sign-in
export interface User {
  id: number;
  email: string;
}

DTO 表示数据传输对象(Data Transfer Object)。

在应用程序中,数据的内部表示通常可能与你想通过 API 公开展示的表示方式不同。数据传输对象(DTO)允许你将内部数据结构转换为更适合 API 消费者的格式。这有助于将内部表示与外部表示解耦,从而实现更好的灵活性和抽象化。

引入新依赖,application/wire_helper.ts

@@ -1,5 +1,5 @@
 import { Config } from "@/infrastructure/config";
-import { BookManager, ReviewManager } from "@/domain/gateway";
+import { BookManager, ReviewManager, UserManager } from "@/domain/gateway";
 import { MySQLPersistence, MongoPersistence } from "@/infrastructure/database";
 import { RedisCache, CacheHelper } from "@/infrastructure/cache";
 
@@ -22,6 +22,10 @@ export class WireHelper {
     return this.sql_persistence;
   }
 
+  userManager(): UserManager {
+    return this.sql_persistence;
+  }
+
   reviewManager(): ReviewManager {
     return this.no_sql_persistence;
   }

最后,添加路由,adapter/router.ts

@@ -2,16 +2,26 @@ import express, { Request, Response } from "express";
 import morgan from "morgan";
 
 import { Book } from "@/domain/model";
-import { BookOperator, ReviewOperator } from "@/application/executor";
+import {
+  BookOperator,
+  ReviewOperator,
+  UserOperator,
+} from "@/application/executor";
 import { WireHelper } from "@/application";
 
 class RestHandler {
   private bookOperator: BookOperator;
   private reviewOperator: ReviewOperator;
+  private userOperator: UserOperator;
 
-  constructor(bookOperator: BookOperator, reviewOperator: ReviewOperator) {
+  constructor(
+    bookOperator: BookOperator,
+    reviewOperator: ReviewOperator,
+    userOperator: UserOperator
+  ) {
     this.bookOperator = bookOperator;
     this.reviewOperator = reviewOperator;
+    this.userOperator = userOperator;
   }
 
   // Get all books
@@ -163,13 +173,39 @@ class RestHandler {
       res.status(404).json({ error: "failed to delete the review" });
     }
   }
+
+  public async userSignUp(req: Request, res: Response): Promise<void> {
+    const userCredential = req.body;
+    try {
+      const result = await this.userOperator.createUser(userCredential);
+      res.status(201).json(result);
+    } catch (err) {
+      console.error(`Failed to create: ${err}`);
+      res.status(404).json({ error: "failed to create the user" });
+    }
+  }
+
+  public async userSignIn(req: Request, res: Response): Promise<void> {
+    const userCredential = req.body;
+    try {
+      const result = await this.userOperator.signIn(
+        userCredential.email,
+        userCredential.password
+      );
+      res.status(200).json(result);
+    } catch (err: any) {
+      console.error(`Failed to sign in: ${err}`);
+      res.status(404).json({ error: err.message });
+    }
+  }
 }
 
 // Create router
 function MakeRouter(wireHelper: WireHelper): express.Router {
   const restHandler = new RestHandler(
     new BookOperator(wireHelper.bookManager(), wireHelper.cacheHelper()),
-    new ReviewOperator(wireHelper.reviewManager())
+    new ReviewOperator(wireHelper.reviewManager()),
+    new UserOperator(wireHelper.userManager())
   );
 
   const router = express.Router();
@@ -188,6 +224,9 @@ function MakeRouter(wireHelper: WireHelper): express.Router {
   router.put("/reviews/:id", restHandler.updateReview.bind(restHandler));
   router.delete("/reviews/:id", restHandler.deleteReview.bind(restHandler));
 
+  router.post("/users", restHandler.userSignUp.bind(restHandler));
+  router.post("/users/sign-in", restHandler.userSignIn.bind(restHandler));
+
   return router;
 }

使用 curl 测试

用户注册:

curl -X POST -H "Content-Type: application/json" -d '{"email": "test-node@example.com", "password": "test-pass"}' http://localhost:3000/users

结果:

{"id":5,"email":"test-node@example.com"}

如果你检查数据库中的记录,你将看到类似下面内容:

+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+
| id | email                 | password                                 | salt | is_admin | created_at              | updated_at              |
+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+
|  6 | test-node@example.com | b436b35185aa826695c4d0cc5de180a9d4be3716 | amGF |        0 | 2024-03-02 09:38:22.000 | 2024-03-02 09:38:22.000 |
+----+-----------------------+------------------------------------------+------+----------+-------------------------+-------------------------+

用户成功登录:

curl -X POST -H "Content-Type: application/json" -d '{"email": "test-node@example.com", "password": "test-pass"}' http://localhost:3000/users/sign-in

结果:

{"id":2,"email":"test-user@example.com"}

用户使用错误密码登录:

curl -X POST -H "Content-Type: application/json" -d '{"email": "test-node@example.com", "password": "wrong-pass"}' http://localhost:3000/users/sign-in

结果:

{"error":"wrong password"}

用户使用不存在的 email 登录

curl -X POST -H "Content-Type: application/json" -d '{"email": "non-existent@example.com", "password": "test-pass"}' http://localhost:3000/users/sign-in

结果:

{"error":"user does not exist"}

注意
为成功和错误响应使用统一的 json 格式是一种最佳实践。 比如:

{"code":0, "message":"ok", "data": {"id":2,"email":"test-user@example.com"}}
{"code":1001, "message":"wrong password", "data": null}

使用 JWT(JSON Web Token)

JWT(JSON Web Token)是一种紧凑、URL 安全的用于表示传输双方之间声明 claims 的方式。它经常用于 Web 应用程序和 API 中的身份验证和授权。

安装 JWT 依赖

npm i jsonwebtoken

添加其类型定义:

npm install @types/jsonwebtoken --save-dev

更新代码

添加 UserPermission 枚举,domain/model/user.ts

@@ -1,3 +1,11 @@
+// UserPermission represents different levels of user permissions.
+export enum UserPermission {
+  PermNone = 0,
+  PermUser = 1,
+  PermAuthor = 2,
+  PermAdmin = 3,
+}
+
 // User represents an app user
 export interface User {
   id?: number;

声明它的业务能力,domain/gateway/user_manager.ts

@@ -1,6 +1,11 @@
-import { User } from "@/domain/model";
+import { User, UserPermission } from "@/domain/model";
 
 export interface UserManager {
   createUser(u: User): Promise<number>;
   getUserByEmail(email: string): Promise<User | null>;
 }
+
+export interface PermissionManager {
+  generateToken(userID: number, email: string, perm: UserPermission): string;
+  hasPermission(token: string, perm: UserPermission): boolean;
+}

实现 2 个方法,infrastructure/token/jwt.ts

import jwt, { SignOptions, VerifyOptions } from "jsonwebtoken";
import { UserPermission } from "@/domain/model/user";
import { PermissionManager } from "@/domain/gateway";

const errInvalidToken = "invalid token";

// UserClaims includes user info.
interface UserClaims {
  userID: number;
  userName: string;
  permission: UserPermission;
  [key: string]: any;
}

// Keeper manages user tokens.
export class TokenKeeper implements PermissionManager {
  private secretKey: Buffer;
  private expireHours: number;

  constructor(secretKey: string, expireInHours: number) {
    this.secretKey = Buffer.from(secretKey);
    this.expireHours = expireInHours;
  }

  // GenerateToken generates a new JWT token.
  generateToken(userID: number, email: string, perm: UserPermission): string {
    const expirationTime =
      Math.floor(Date.now() / 1000) + this.expireHours * 3600;
    const payload: UserClaims = {
      userID,
      userName: email,
      permission: perm,
      exp: expirationTime,
    };

    const options: SignOptions = {
      algorithm: "HS256",
    };

    return jwt.sign(payload, this.secretKey, options);
  }

  // ExtractToken extracts the token from the signed string.
  extractToken(tokenResult: string): UserClaims {
    const options: VerifyOptions = {
      algorithms: ["HS256"],
    };

    try {
      const decoded = jwt.verify(
        tokenResult,
        this.secretKey,
        options
      ) as UserClaims;
      return decoded;
    } catch (error) {
      throw new Error(errInvalidToken);
    }
  }

  // HasPermission checks if user has the given permission.
  hasPermission(tokenResult: string, perm: UserPermission): boolean {
    const claims = this.extractToken(tokenResult);
    return claims.permission >= perm;
  }
}

添加 Tokens 相关的配置项,infrastructure/config/config.ts

@@ -10,6 +10,8 @@ interface DBConfig {
 interface ApplicationConfig {
   port: number;
   page_size: number;
+  token_secret: string;
+  token_hours: number;
 }
 
 export interface CacheConfig {

置入其值,config.json

@@ -1,7 +1,9 @@
 {
   "app": {
     "port": 3000,
-    "page_size": 5
+    "page_size": 5,
+    "token_secret": "I_Love_LiteRank",
+    "token_hours": 48
   },
   "db": {
     "file_name": "test.db",

生成并返回 tokens,application/executor/user_operator.ts

@@ -1,7 +1,8 @@
 import { createHash } from "crypto";
 
-import { UserManager } from "@/domain/gateway";
-import { UserCredential, User } from "@/application/dto";
+import { PermissionManager, UserManager } from "@/domain/gateway";
+import { UserCredential, User, UserToken } from "@/application/dto";
+import { UserPermission } from "@/domain/model";
 
 const saltLen = 4;
 const errEmptyEmail = "empty email";
@@ -10,9 +11,11 @@ const errDoesNotExist = "user does not exist";
 
 export class UserOperator {
   private userManager: UserManager;
+  private permManager: PermissionManager;
 
-  constructor(u: UserManager) {
+  constructor(u: UserManager, p: PermissionManager) {
     this.userManager = u;
+    this.permManager = p;
   }
 
   async createUser(uc: UserCredential): Promise<User | null> {
@@ -33,7 +36,7 @@ export class UserOperator {
     return { id, email: uc.email };
   }
 
-  async signIn(email: string, password: string): Promise<User | null> {
+  async signIn(email: string, password: string): Promise<UserToken | null> {
     if (!email) {
       throw new Error(errEmptyEmail);
     }
@@ -48,10 +51,19 @@ export class UserOperator {
     if (user.password !== passwordHash) {
       throw new Error("wrong password");
     }
-    return { id: user.id!, email: user.email };
+    const token = this.permManager.generateToken(
+      user.id!,
+      user.email,
+      calcPerm(user.is_admin)
+    );
+    return { user: { id: user.id!, email: user.email }, token };
   }
 }
 
+function calcPerm(isAdmin: boolean): UserPermission {
+  return isAdmin ? UserPermission.PermAdmin : UserPermission.PermUser;
+}
+
 function randomString(length: number): string {
   const charset: string =
     "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

添加 DTO,application/dto/user.ts

@@ -9,3 +9,8 @@ export interface User {
   id: number;
   email: string;
 }
+
+export interface UserToken {
+  user: User;
+  token: string;
+}

微调 application/wire_helper.ts

@@ -1,13 +1,20 @@
 import { Config } from "@/infrastructure/config";
-import { BookManager, ReviewManager, UserManager } from "@/domain/gateway";
+import {
+  BookManager,
+  PermissionManager,
+  ReviewManager,
+  UserManager,
+} from "@/domain/gateway";
 import { MySQLPersistence, MongoPersistence } from "@/infrastructure/database";
 import { RedisCache, CacheHelper } from "@/infrastructure/cache";
+import { TokenKeeper } from "@/infrastructure/token";
 
 // WireHelper is the helper for dependency injection
 export class WireHelper {
   private sql_persistence: MySQLPersistence;
   private no_sql_persistence: MongoPersistence;
   private kv_store: RedisCache;
+  private tokenKeeper: TokenKeeper;
 
   constructor(c: Config) {
     this.sql_persistence = new MySQLPersistence(c.db.dsn, c.app.page_size);
@@ -16,12 +23,17 @@ export class WireHelper {
       c.db.mongo_db_name
     );
     this.kv_store = new RedisCache(c.cache);
+    this.tokenKeeper = new TokenKeeper(c.app.token_secret, c.app.token_hours);
   }
 
   bookManager(): BookManager {
     return this.sql_persistence;
   }
 
+  permManager(): PermissionManager {
+    return this.tokenKeeper;
+  }
+
   userManager(): UserManager {
     return this.sql_persistence;
   }

添加 PermCheck 中间件到创建、更新、删除路由中,adapter/router.ts

@@ -1,13 +1,14 @@
 import express, { Request, Response } from "express";
 import morgan from "morgan";
 
-import { Book } from "@/domain/model";
+import { Book, UserPermission } from "@/domain/model";
 import {
   BookOperator,
   ReviewOperator,
   UserOperator,
 } from "@/application/executor";
 import { WireHelper } from "@/application";
+import { permCheck } from "@/adapter/middleware";
 
 class RestHandler {
   private bookOperator: BookOperator;
@@ -202,19 +203,32 @@ class RestHandler {
 
 // Create router
 function MakeRouter(wireHelper: WireHelper): express.Router {
+  const pm = wireHelper.permManager();
   const restHandler = new RestHandler(
     new BookOperator(wireHelper.bookManager(), wireHelper.cacheHelper()),
     new ReviewOperator(wireHelper.reviewManager()),
-    new UserOperator(wireHelper.userManager())
+    new UserOperator(wireHelper.userManager(), pm)
   );
 
   const router = express.Router();
 
   router.get("/books", restHandler.getBooks.bind(restHandler));
   router.get("/books/:id", restHandler.getBook.bind(restHandler));
-  router.post("/books", restHandler.createBook.bind(restHandler));
-  router.put("/books/:id", restHandler.updateBook.bind(restHandler));
-  router.delete("/books/:id", restHandler.deleteBook.bind(restHandler));
+  router.post(
+    "/books",
+    permCheck(pm, UserPermission.PermAuthor),
+    restHandler.createBook.bind(restHandler)
+  );
+  router.put(
+    "/books/:id",
+    permCheck(pm, UserPermission.PermAuthor),
+    restHandler.updateBook.bind(restHandler)
+  );
+  router.delete(
+    "/books/:id",
+    permCheck(pm, UserPermission.PermAuthor),
+    restHandler.deleteBook.bind(restHandler)
+  );
   router.get(
     "/books/:id/reviews",
     restHandler.getReviewsOfBook.bind(restHandler)

permCheck 的实现,adapter/middleware.ts

import { Request, Response, NextFunction } from "express";
import { UserPermission } from "@/domain/model";
import { PermissionManager } from "@/domain/gateway";

const tokenPrefix = "Bearer ";

// permCheck middleware checks user permission
export function permCheck(
  permManager: PermissionManager,
  allowPerm: UserPermission
): (req: Request, res: Response, next: NextFunction) => void {
  return async (req: Request, res: Response, next: NextFunction) => {
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith(tokenPrefix)) {
      return res.status(401).json({ error: "token is required" });
    }

    const token = authHeader.replace(tokenPrefix, "");

    try {
      if (!permManager.hasPermission(token, allowPerm)) {
        return res.status(401).json({ error: "Unauthorized" });
      }
      next();
    } catch (error: any) {
      return res.status(401).json({ error: error.message });
    }
  };
}

以上就是 API 中使用 JWT 的所需更改。让我们再次测试一下服务器功能!

使用 curl 测试

用户登录获得 Token

curl -X POST -H "Content-Type: application/json" -d '{"email": "test-user@example.com", "password": "test-pass"}' http://localhost:3000/users/sign-in

结果:

{
  "user": { "id": 2, "email": "test-user@example.com" },
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJ1c2VyX25hbWUiOiJ0ZXN0LXVzZXJAZXhhbXBsZS5jb20iLCJwZXJtaXNzaW9uIjoxLCJleHAiOjE3MDkyNzA1MzV9.eJp5Yr9YZMxHFs5eId78bEJ6draO178jysquZ2VV9v8"
}

将 token 放到 https://jwt.io/ 调试器中,可查看 token 的负载和签名是否正常。

JWT Debugger

创建新的图书,携带合法正常的 Token

curl -X POST \
  http://localhost:3000/books \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySUQiOjIsInVzZXJOYW1lIjoidGVzdC11c2VyQGV4YW1wbGUuY29tIiwicGVybWlzc2lvbiI6MywiZXhwIjoxNzA5NTQ5OTk4LCJpYXQiOjE3MDkzNzcxOTh9.olJ2ITlSEKYiYAymtmALK-qTfK9y7Kp1Q0mCmBKtBCw' \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Test Book",
    "author": "John Doe",
    "published_at": "2003-01-01",
    "description": "A sample book description",
    "isbn": "1234567890",
    "total_pages": 100
}'

成功结果:

{
  "title": "Test Book",
  "author": "John Doe",
  "published_at": "2003-01-01",
  "description": "A sample book description",
  "isbn": "1234567890",
  "total_pages": 100,
  "id": 16
}

创建新的图书,携带合法 Token,但是权限不够

curl -X POST \
  http://localhost:3000/books \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySUQiOjYsInVzZXJOYW1lIjoidGVzdC1ub2RlQGV4YW1wbGUuY29tIiwicGVybWlzc2lvbiI6MSwiZXhwIjoxNzA5NTUwNDc0LCJpYXQiOjE3MDkzNzc2NzR9.PBUUkMw5xTia3-8_rHc1iTMffeW8u2mpC0qrLmq5YQE' \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Test Book",
    "author": "John Doe",
    "published_at": "2003-01-01",
    "description": "A sample book description",
    "isbn": "1234567890",
    "total_pages": 100
}'

结果:

{"error":"Unauthorized"}

创建新的图书,不带 Token

curl -X POST \
  http://localhost:3000/books \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Test Book",
    "author": "John Doe",
    "published_at": "2003-01-01",
    "description": "A sample book description",
    "isbn": "1234567890",
    "total_pages": 100
}'

结果:

{"error":"token is required"}

创建新的图书,带一个假 Token

curl -X POST \
  http://localhost:3000/books \
  -H 'Authorization: Bearer FAKE_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Test Book",
    "author": "John Doe",
    "published_at": "2003-01-01",
    "description": "A sample book description",
    "isbn": "1234567890",
    "total_pages": 100
}'

结果:

{"error":"invalid token"}

完美! 🎉

现在,你的部分端点已经被权限认证机制保护起来啦。

上页下页