Xây dựng Login gRPC API với Node.js và Express (Phần 3)

 


Chào mừng các bạn trở lại với inovationthinking. Đây là phần cuối cùng của loạt bài "Xây dựng gRPC API với Node.js và Express".Để xem lại 2 phần trước các bạn vui lòng xem lại  ở đây:

Chúng ta tiếp tục với phần 2 đang viết dở.

Tạo các Authentication Controllers

Vì chúng ta đã định nghĩa các service, tiếp theo hãy tạo authentication controller để:

  • Xác thực và đăng ký người dùng mới.
  • Xác thực và đăng nhập người dùng đã đăng ký.
  • Xác thực và refresh access token đã hết hạn.
Trước đó chúng ta cài đặt Bcrypt package. Mục đích là để mã hóa mật khẩu của khách cung cấp.Lúc lưu vào database chúng ta cũng chỉ được phép lưu mật khẩu đã mã hóa.

yarn add bcryptjs && yarn add -D @types/bcryptjs

Bây giờ tạo một file server/controllers/auth.controller.ts với các import sau:
server/controllers/auth.controller.ts
import bcrypt from 'bcryptjs';
import * as grpc from '@grpc/grpc-js';
import {
  createUser,
  findUniqueUser,
  findUser,
  signTokens,
} from '../services/user.service';
import { SignUpUserInput__Output } from '../../pb/auth/SignUpUserInput';
import { SignInUserInput__Output } from '../../pb/auth/SignInUserInput';
import { SignInUserResponse__Output } from '../../pb/auth/SignInUserResponse';
import { SignUpUserResponse } from '../../pb/auth/SignUpUserResponse';
import { RefreshTokenInput__Output } from '../../pb/auth/RefreshTokenInput';
import { RefreshTokenResponse } from '../../pb/auth/RefreshTokenResponse';
import { signJwt, verifyJwt } from '../utils/jwt';
import customConfig from '../config/default';
import redisClient from '../utils/connectRedis';
Controller để đăng ký người dùng mới

server/controllers/auth.controller.ts
// [...] imports

// [...] Register New User
export const registerHandler = async (
  req: grpc.ServerUnaryCall<SignUpUserInput__Output, SignUpUserResponse>,
  res: grpc.sendUnaryData<SignUpUserResponse>
) => {
  try {
    const hashedPassword = await bcrypt.hash(req.request.password, 12);
    const user = await createUser({
      email: req.request.email.toLowerCase(),
      name: req.request.name,
      password: hashedPassword,
      photo: req.request.photo,
      provider: 'local',
    });

    res(null, {
      user: {
        id: user.id,
        name: user.name,
        email: user.email,
        photo: user.photo!,
        provider: user.provider!,
        role: user.role!,
        created_at: {
          seconds: user.created_at.getTime() / 1000,
        },
        updated_at: {
          seconds: user.updated_at.getTime() / 1000,
        },
      },
    });
  } catch (err: any) {
    if (err.code === 'P2002') {
      res({
        code: grpc.status.ALREADY_EXISTS,
        message: 'Email already exists',
      });
    }
    res({ code: grpc.status.INTERNAL, message: err.message });
  }
};
Controller để đăng nhập người dùng

server/controllers/auth.controller.ts
// [...] imports

// [...] Register New User

// [...] Login User
export const loginHandler = async (
  req: grpc.ServerUnaryCall<
    SignInUserInput__Output,
    SignInUserResponse__Output
  >,
  res: grpc.sendUnaryData<SignInUserResponse__Output>
) => {
  try {
    // Get the user from the collection
    const user = await findUser({ email: req.request.email });

    // Check if user exist and password is correct
    if (!user || !(await bcrypt.compare(req.request.password, user.password))) {
      res({
        code: grpc.status.INVALID_ARGUMENT,
        message: 'Invalid email or password',
      });
    }

    // Create the Access and refresh Tokens
    const { access_token, refresh_token } = await signTokens(user);

    // Send Access Token
    res(null, {
      status: 'success',
      access_token,
      refresh_token,
    });
  } catch (err: any) {
    res({
      code: grpc.status.INTERNAL,
      message: err.message,
    });
  }
};
Controller để làm mới Access Token

server/controllers/auth.controller.ts
// [...] imports

// [...] Register New User

// [...] Login User

// [...] Refresh Token
export const refreshAccessTokenHandler = async (
  req: grpc.ServerUnaryCall<RefreshTokenInput__Output, RefreshTokenResponse>,
  res: grpc.sendUnaryData<RefreshTokenResponse>
) => {
  try {
    // Get the refresh token from cookie
    const refresh_token = req.request.refresh_token as string;

    const message = 'Could not refresh access token';
    if (!refresh_token) {
      res({
        code: grpc.status.PERMISSION_DENIED,
        message,
      });
    }

    // Validate the Refresh token
    const decoded = verifyJwt<{ sub: string }>(
      refresh_token,
      'refreshTokenPublicKey'
    );

    if (!decoded) {
      res({
        code: grpc.status.PERMISSION_DENIED,
        message,
      });
      return;
    }

    // Check if the user has a valid session
    const session = await redisClient.get(decoded?.sub);
    if (!session) {
      res({
        code: grpc.status.PERMISSION_DENIED,
        message,
      });
      return;
    }

    // Check if the user exist
    const user = await findUniqueUser({ id: JSON.parse(session).id });

    if (!user) {
      res({
        code: grpc.status.PERMISSION_DENIED,
        message,
      });
    }

    // Sign new access token
    const access_token = signJwt({ sub: user.id }, 'accessTokenPrivateKey', {
      expiresIn: `${customConfig.accessTokenExpiresIn}m`,
    });

    // Send response
    res(null, { access_token, refresh_token });
  } catch (err: any) {
    res({
      code: grpc.status.INTERNAL,
      message: err.message,
    });
  }
};
Bảo vệ các Private RPC Methods

server/middleware/deserializeUser.ts
import { User } from '@prisma/client';
import { findUniqueUser } from '../services/user.service';
import redisClient from '../utils/connectRedis';
import { verifyJwt } from '../utils/jwt';

export const deserializeUser = async (
  access_token: string
): Promise<User | null> => {
  try {
    // Get the token
    if (!access_token) {
      return null;
    }

    // Validate Access Token
    const decoded = verifyJwt<{ sub: string }>(
      access_token,
      'accessTokenPublicKey'
    );

    if (!decoded) {
      return null;
    }

    // Check if user has a valid session
    const session = await redisClient.get(decoded.sub);

    if (!session) {
      return null;
    }

    // Check if user still exist
    const user = await findUniqueUser({ id: JSON.parse(session).id });

    if (!user) {
      return null;
    }

    return user;
  } catch (err: any) {
    return null;
  }
};
Lấy thông tin người dùng đã đăng nhập

server/controllers/user.controller.tsage/bcryptjs
import * as grpc from '@grpc/grpc-js';
import { GetMeInput__Output } from '../../pb/auth/GetMeInput';
import { UserResponse } from '../../pb/auth/UserResponse';
import { deserializeUser } from '../middleware/deserializeUser';

export const getMeHandler = async (
  req: grpc.ServerUnaryCall<GetMeInput__Output, UserResponse>,
  res: grpc.sendUnaryData<UserResponse>
) => {
  try {
    const user = await deserializeUser(req.request.access_token);
    if (!user) {
      res({
        code: grpc.status.NOT_FOUND,
        message: 'Invalid access token or session expired',
      });
      return;
    }

    res(null, {
      user: {
        id: user.id,
        name: user.name,
        email: user.email,
        photo: user.photo!,
        provider: user.provider!,
        role: user.role!,
        created_at: {
          seconds: user.created_at.getTime() / 1000,
        },
        updated_at: {
          seconds: user.updated_at.getTime() / 1000,
        },
      },
    });
  } catch (err: any) {
    res({
      code: grpc.status.INTERNAL,
      message: err.message,
    });
  }
};
Tạo các gRPC Server trong Node.js

server/app.ts
import path from 'path';
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import { ProtoGrpcType } from '../pb/services';
import { AuthServiceHandlers } from '../pb/auth/AuthService';
import {
  loginHandler,
  refreshAccessTokenHandler,
  registerHandler,
} from './controllers/auth.controller';
import customConfig from './config/default';
import connectDB from './utils/prisma';
import { getMeHandler } from './controllers/user.controller';

const options: protoLoader.Options = {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
};

const PORT = customConfig.port;
const PROTO_FILE = '../proto/services.proto';
const packageDef = protoLoader.loadSync(
  path.resolve(__dirname, PROTO_FILE),
  options
);

const proto = grpc.loadPackageDefinition(
  packageDef
) as unknown as ProtoGrpcType;

const authPackage = proto.auth;

const server = new grpc.Server();
server.addService(authPackage.AuthService.service, {
  SignUpUser: (req, res) => registerHandler(req, res),
  SignInUser: (req, res) => loginHandler(req, res),
  RefreshToken: (req, res) => refreshAccessTokenHandler(req, res),
  GetMe: (req, res) => getMeHandler(req, res),
} as AuthServiceHandlers);
server.bindAsync(
  `0.0.0.0:${PORT}`,
  grpc.ServerCredentials.createInsecure(),
  (err, port) => {
    if (err) {
      console.error(err);
      return;
    }
    server.start();
    connectDB();
    console.log(`? Server listening on ${port}`);
  }
);
Giải thích về code trên
  • Đầu tiên chúng ta import các phụ thuộc
  • Tiếp theo, chúng ta đã kích hoạt hàm loadSync() và cung cấp cho nó đường dẫn đến tệp proto và một configuration object. Hàm loadSync() được cung cấp bởi @grpc/proto-loader sẽ tải các tệp proto và trả về các định nghĩa Protobuf.
  • Tiếp theo, chúng ta đã gọi hàm loadPackageDefinition() do gói @grpc/grpc-js cung cấp để trả về định nghĩa gói gRPC dưới dạng đối tượng gRPC phân cấp.
  • Tiếp theo, chúng ta đã tạo một máy chủ gRPC Node.js mới và gọi phương thức .addService() để giúp chúng ta thêm các dịch vụ gRPC đã định nghĩa vào server.
  • Để khởi động gRPC server, chúng ta cần gọi phương thức bindAsync() và liên kết nó với cổng được khai báo sẵn. Theo mặc định, gRPC hoạt động trên HTTP/2 và vì chúng ta đang ở trong môi trường phát triển nên chúng ta cần tạo kết nối không an toàn bằng cách gọi phương thức grpc.ServerCredentials.createInsecure() làm đối số thứ hai.

Tạo các gRPC Client trong Node.js

Các gRPC client là nơi chúng ta sẽ call các method RPC đã được định nghĩa ở gRPC server.
Tạo tệp client/app.ts và thêm mã sau đây.

client/app.ts
import path from 'path';
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import { ProtoGrpcType } from '../pb/services';
import customConfig from '../server/config/default';

const options: protoLoader.Options = {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
};
const PORT = customConfig.port;
const PROTO_FILE = '../proto/services.proto';
const packageDef = protoLoader.loadSync(
  path.resolve(__dirname, PROTO_FILE),
  options
);

const proto = grpc.loadPackageDefinition(
  packageDef
) as unknown as ProtoGrpcType;

const client = new proto.auth.AuthService(
  `0.0.0.0:${PORT}`,
  grpc.credentials.createInsecure()
);
const deadline = new Date();
deadline.setSeconds(deadline.getSeconds() + 5);
client.waitForReady(deadline, (err) => {
  if (err) {
    console.error(err);
    return;
  }
  onClientReady();
});

function onClientReady() {
  signUpUser()
}

function signUpUser() {
  client.SignUpUser(
    {
      name: 'Admin',
      email: 'admin@admin.com',
      password: 'password123',
      passwordConfirm: 'password123',
      photo: 'default.png',
    },
    (err, res) => {
      if (err) {
        console.error(err);
        return;
      }
      console.log(res);
    }
  );
}

function signInUser() {
  client.SignInUser(
    {
      email: 'admin@admin.com',
      password: 'password123',
    },
    (err, res) => {
      if (err) {
        console.error(err);
        return;
      }
      console.log(res);
    }
  );
}

function refreshToken() {
  client.RefreshToken(
    {
      refresh_token: '',
    },
    (err, res) => {
      if (err) {
        console.error(err);
        return;
      }
      console.log(res);
    }
  );
}

function getAuthenticatedUser() {
  client.getMe(
    {
      access_token: '',
    },
    (err, res) => {
      if (err) {
        console.error(err);
        return;
      }
      console.log(res);
    }
  );
}
Đoạn mã trên tương tự như những gì chúng ta có trong tệp server/app.ts. Ở đây, chúng tôi đã tải các định nghĩa Protobuf và tạo một phiên bản mới của lớp AuthService với số cổng và kết nối không an toàn.

Khởi động các gRPC Client và Server

Cài đặt gói ts-node-dev để giúp chúng tôi khởi động server và client gRPC. Ngoài ra, gói này sẽ hot-reload server gRPC khi có bất kỳ thay đổi file nào.
yarn add ts-node-dev
Cập nhật tệp package.json với các tập lệnh sau:
{
"scripts": {
    "start:server": "ts-node-dev --respawn --transpile-only server/app.ts",
    "start:client": "ts-node-dev --transpile-only client/app.ts",
    "db:migrate": "npx prisma migrate dev --name updated-user-entity --create-only --schema ./server/prisma/schema.prisma && yarn prisma generate --schema ./server/prisma/schema.prisma",
    "db:push": "npx prisma db push --schema ./server/prisma/schema.prisma"
  }
}
  • start:server - script này sẽ khởi động server gRPC
  • start:client - ​​script này cũng sẽ khởi động ứng dụng client gRPC
Đảm bảo cả Docker Redis và PostgreSQL đều đang chạy.Mở terminal lên và chạy lệnh sau để khởi động gRPC server ở cổng 8000 
yarn start:server

Gọi phương thức RPC để đăng ký người dùng

Bây giờ, hãy sử dụng ứng dụng client gRPC để gọi phương thức SignUpUser RPC, nhằm thêm người dùng mới vào cơ sở dữ liệu PostgreSQL.

// (...) code above

// [...] Register new user
function onClientReady() {
  signUpUser();
}

function signUpUser() {
  client.SignUpUser(
    {
      name: 'Admin',
      email: 'admin@admin.com',
      password: 'password123',
      passwordConfirm: 'password123',
      photo: 'default.png',
    },
    (err, res) => {
      if (err) {
        console.error(err);
        return;
      }
      console.log(res);
    }
  );
}
Ở phần trên, chúng ta đã gọi phương thức SignUpUser RPC từ client gRPC và truyền một đối tượng chứa thông tin xác thực cần thiết để đăng ký người dùng mới.

Đối số thứ hai là callback function sẽ được gọi trong server để trả kết quả cho client. Trong callback function, chúng ta đã log lại cả lỗi bất kỳ và kết quả được trả về từ máy chủ gRPC.

Bây giờ trong teminal khác, chạy lệnh sau start:client để khởi động ứng dụng client gRPC. Khi ứng dụng client gRPC đã sẵn sàng, phương thức SignUpUser RPC sẽ được gọi để đăng ký người dùng mới.

Trong vòng vài giây, bạn sẽ thấy kết quả trong terminal:


Gọi phương thức RPC để đăng nhập người dùng

Bây giờ hãy thay thế hàm signUpUser() trong hàm onClientReady() bằng hàm signInUser() và chạy lại ứng dụng gRPC để đăng nhập người dùng đã đăng ký.


Gọi RPC để lấy thông tin người dùng được xác thực

Thay thế hàm signInUser() trong hàm onClientReady() bằng hàm getAuthenticatedUser() để truy xuất thông tin hồ sơ của người dùng đã được xác thực.

Trước khi gọi phương thức RPC, hãy sao chép mã thông báo truy cập từ thiết bị đầu cuối và gán nó cho thuộc tính access_token: '' trong phương thức client.getMe().

Bây giờ hãy chạy lại ứng dụng client gRPC và bạn sẽ thấy thông tin của người dùng đã được xác thực trong teminal.


Kết luận

Chúc mừng bạn đã đi đến cuối cùng. Trong bài viết này, bạn đã tìm hiểu cách xây dựng API gRPC của Node.js bằng PostgreSQL, Prisma, Redis và Docker-compose. Ngoài ra, bạn đã học cách tạo ứng dụng client gRPC để gọi các phương thức RPC trên API gRPC.

Nhận xét

Bài đăng phổ biến từ blog này

Cài đặt SSL cho website sử dụng certbot

Xây dựng một hệ thống comment real-time hoặc chat đơn giản sử dụng Pusher

CÁC BÀI TẬP SQL CƠ BẢN - PART 1

Xây dựng một hệ thống tracking hành vi người dùng (phần 1)

Xây dựng một hệ thống tracking hành vi người dùng (phần 2)

Enterprise architecture trên 1 tờ A4

Web caching (P2)

Bàn về async/await trong vòng lặp javascript

Web caching (P1)

Cài đặt môi trường để code website Rails