一个低调的单终端登录系统

尝试在一周内学习 c++、grpc、bazel、docker compose、google test、gmock,然后实现一个单终端登录系统。

需求说明:

设计一套单终端登录系统

1. 具备注册登录功能
2. 一个用户只能在一个设备上登录,切换终端登录时,其他已登录的终端会被踢出

1 部署环境,构思项目流程

grpc

bazel

Bazel: Building a C++ Project

docker 莱鸟教程

docker

项目的大致流程

  1. 部署 grpc,bazel
  2. 基于 grpc c++ 编写命令行客户端,采用 bazel 进行编译
  3. 基于 grpc c++ 编写服务端,采用 bazel 进行编译
  4. 部署 docker,docker compose
  5. 创建 2 个镜像,服务端 user_server 和 mysql,分别基于 user_server 镜像和 mysq 镜像启动容器
  6. 通过 bazel 编译启动客户端 user_client
  7. 使用Docker部署项目,并使用Docker Compose编排容器

思路很清晰,而现实很残酷,grpc 下载慢(多下几次),编译源码蹦出来各种坑(主要是依赖库没下好)。

一个低调的单终端登录系统_第1张图片

嘴上一直说着不弄了,还好身体很诚实,一直坐着敲敲,终于整出了一个振奋人心的 “Hello world”。

2 表设计

用户表

CREATE TABLE `user` (
  `id` bigint(20) NOT NULL COMMENT '主键id',
  `mobile` varchar(255) NOT NULL COMMENT '手机号,用于登录',
  `password` varchar(32) DEFAULT NULL COMMENT '加密密码',
  `create_time` datetime(0) NULL COMMENT '创建时间',
  `update_time` datetime(0) NULL COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE INDEX `phone_idx`(`phone`) USING HASH,
  INDEX()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

token表

CREATE TABLE `billing`.`Untitled`  (
  `id` bigint(20) NOT NULL COMMENT '主键id',
  `phone` varchar(255) NULL COMMENT '手机号',
  `token` varchar(255) NULL COMMENT '加密后的 token',
  `status` tinyint(3) NULL COMMENT '状态:0生效 1失效',
  `expire_time` datetime(0) NULL COMMENT '过期时间',
  `device` varchar(255) NULL COMMENT '设备号',
  `create_time` datetime(0) NULL COMMENT '创建时间',
  `update_time` datetime(0) NULL COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE INDEX `phone_idx`(`phone`) USING HASH,
  INDEX()
);

3 安全性设计

3.1 协议

参考:常见web安全问题

Even though users may use strong passwords, attackers might be able to eavesdrop on their connections. Use HTTPS to avoid sending passwords (or any other sensitive data) over plain HTTP connections because they will be vulnerable to password sniffing.

使用 HTTPS 替代 HTTP,HTTPS 比 HTTP 多了一层 SSL 安全协议,其主要任务是负责压缩和加密。

3.2 密码加密

PBKDF2 算法与 SHA256 哈希结合,这是 NIST 推荐的密码扩展机制。PBKDF2 算法通过多次 hash 来对密码进行加密。原理是通过 password 和 salt 进行 hash,然后将结果作为 salt 再与 password 进行 hash,多次重复此过程,生成最终的密文。

一个低调的单终端登录系统_第2张图片

3.2.1 PBKDF2 算法

DK = PBKDF2(PRF, Password, Salt, c, dkLen)

PRF 是一个伪随机函数,可以简单的理解为 Hash 函数,它会输出固定长度的字符串。

Password 是用来生成密钥的原文密码。

Salt 是一个加密用的盐值。

c 是进行重复计算的次数。

dkLen 是期望得到的密钥的长度。

DK 是最后产生的密钥。

3.2.2 SHA(安全哈希算法)

安全散列算法(Secure Hash Algorithm,缩写为SHA)是一个密码哈希函数家族,是FIPS所认证的安全散列算法。能计算出一个数字消息所对应到的,长度固定的字符串(又称消息摘要)的算法。

SHA家族:SHA-1(摘要长度为 160),SHA-2(SHA-224 、SHA-256、SHA-384、SHA-512),其中 224,256,384,512 是生成的消息摘要的长度。

SHA256算法原理详解

sha256的C++实现

一个低调的单终端登录系统_第3张图片

(图片来源)

3.2.3 结合 PBKDF2 算法 与 SHA256 哈希算法加密用户密码

// 计算次数
#define COUNT = 2000;
// 最终加密密码长度
#define LENGTH = 32;

// 待加密密码
std::string password = "123456"; 
// 随机盐值
std::string salt = Encrypt::randomSalt();;
// 初始化Sha256对象
mySHA::Sha256 sha256; 
// 经SHA256加密后的消息摘要
std::string message_digest = sha256.getHexMessageDigest(message); 
// 初始化 Pbkdf2 对象
myPbkdf2::Pbkdf2 pbkdf2;
std::string dk = pbkdf2.getDk(message_digest,password,password,salt,COUNT,LENGTH);

4 单终端登录控制

账号每次登录成功后,给客户端下发一个 token,用于后续的接口调用。服务端通过对 token 进行校验来对用户进行认证。

token:对 phone 进行哈希,返回 32 位的字符串,返回客户端,再用 PBKDF2 算法加密保存到数据库。

  1. 客户端调用登录接口,传参 phone, password, deviceId;
  2. 服务端校验 phone 和 password 是否正确,正确则往下执行;
  3. 根据 phone 查询 token 表
  • 为空,则生成新的 token 记录,status 置为 0 生效,过期时间置为 2 小时后,保存。
  • 不为空,判断时间是否过期,判断设备 id 是否一致,设备 id 一致则将上一条记录的状态置为 1 失效,新增一条token记录,保存。设备 id 不一致,通过deviceId_old,往设备推送一条 push 消息,客户端接收到之后自动删除本地缓存的账号信息,并提示用户。

5 下线消息推送

通过 gRPC 框架的 stream 机制来实现下线通知的推送。

5.1 gRPC Streaming

普通的 gRPC 是直接返回一个 Reply对象,而流式响应可以通过方法返回多个 HelloReply对象,对象流序列化后流式返回。

5.1.1 Server-side streaming 服务端流式

客户端发起一次普通的 RPC 请求,服务端通过流式响应,多次发送数据集。

一个低调的单终端登录系统_第4张图片

5.1.2 Client-side streaming 客户端流式

客户端通过流式发起多次 RPC 请求给服务端,服务端发起一次响应给客户端

一个低调的单终端登录系统_第5张图片

5.1.3 Bidiretional streaming 双向流式

客户端以流式的方式发起请求,服务端同样以流式的方式响应请求。

一个低调的单终端登录系统_第6张图片

5.2 消息推送实现

基于服务端流式 RPC,向客户端推送下线通知。

5 项目的诞生

5.1 创建接口定义语言(interface definition language)

通过 protobuf 定义客户端和服务端交换的数据格式,以及服务端与客户端之间的 RPC 调用接口。如下:user.proto

syntax = "proto3";

package user;

service User {
	
    rpc SignUp(UserInfo) returns (UserReply) {}

    rpc SignIn(UserInfo) returns (UserReply) {}
}

message UserInfo {
    string phone = 1;
    string password = 2;
    String device = 3;
}

message UserReply {
    int32 err = 1;
    string msg = 2;
    Data data = 3;
}

message Data {
    string token = 1;
}

通过 protoc 工具生成客户端和服务端代码

protoc -I=./ --cpp_out=./ user.proto

protoc -I=./ --grpc_out=./ --plugin=protoc-gen-grpc=`which grpc_cpp_plugin` user.proto

当前目录下会生成四个文件:

user.pb.cc:the header which declares your generated message classes   

user.pb.h:which contains the implementation of your message classes

user.grpc.pb.cc:the header which declares your generated service classes

user.grpc.pb.h:which contains the implementation of your service classes

5.2 客户端伪代码 

std::string SignUp(const std::string& mobile, const std::string& password, const std::string device) {
        UserRequest request;
        request.set_mobile(mobile);
        // md5 第一次加密,结合盐值
        std::string salt = Encrypt::randomSalt();
        std::string pwdHash = Encrypt::md5(password + salt);
        request.set_password(pwdHash);   
        request.set_device(device);
        
        UserReply reply;
        ClientContext context;

        Status status = stub_->SignUp(&context, request, &reply);  
        if (status.ok()) {
            return "sign up success";
        } else {
            // 返回服务端回传的错误信息
            return message;
        }
    }

std::string SignIn(const std::string& mobile, const std::string& password, const std::string device) {
        UserRequest request;
        // 密码加密
        request.set_mobile(mobile);
        std::string salt = Encrypt::randomSalt();
        std::string pwdHash = Encrypt::md5(password + salt);
        request.set_password(pwdHash);
        request.set_device(device);

        UserReply reply;     
        ClientContext context;

        Status status = stub_->SignIn(&context, request, &reply);
        // 根据状态码返回不同的信息
        if (status.ok()) {
            return "sign in success";
        } else {
            // 返回服务端回传的错误信息
            return message;
        }
}

5.3 服务端伪代码

ServerResult SignUp(ServerContext* context, const UserRequest* request,
                  UserReply* reply) override {
        UserDB userDb;
        ServerResult result;
        User user userDb->getUser(request->phone(),pwdHash);
        if(user != null){
            result->setErr(1);
            result->sermessage("user is exit, please sign in");
        }
        // PBKDF2算法加密密码
        // 根据 phone 生成 32 位的 token,回传客户端
        // PBKDF2算法加密token保存到数据库
}

ServerResult SignIn(ServerContext* context, const UserRequest* request,
                  UserReply* reply) override {
        UserDB userDb;
        ServerResult result;
        User user userDb->getUser(request->phone(),pwdHash);
        // 服务端校验 phone 和 password 是否正确
        // PBKDF2算法加密token再与 DB 中的 token 比较
        // 比较设备号
        if(device != ole_device){
            // 启动下线通知
        }
}

6 Bazel 构建项目

Bazel的编译基于工作区(workspace)。工作区是一个存放了所有源代码和 Bazel 编译输出文件的目录,是整个项目的根目录。指定一个目录为 Bazel 的工作区,就只要在该目录下创建一个空的WORKSPACE 文件即可。

6.1 WORKSPACE 文件

 WORKSPACE 文件  用于指定当前文件夹就是一个 Bazel 的工作区,所以 WORKSPACE 文件总是存在于项目的根目录下。此外,WORKSPACE 文件还用于引入其他文件系统或网络的第三方库(因此在不依赖第三方库构建时,WORKSPACE基本没用,可以看到官网 examples 中的 WORKSPACE 是个空文件)

外部依赖:在 WORKSPACE 中引入外部依赖

  • local_repository:本地
  • git_repository:git仓库
  • http_archive:网络下载

本项目的 WORKSPACE 文件

# load加载http.bzl工具,用于以http_archive的方式进行依赖的下载
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

http_archive(
    # 下载一个压缩格式的 Bazel 仓库,并解压出来,然后绑定使用。BUILD 文件根据 name 引用
    name = "com_github_grpc_grpc",
    urls = [
        "https://github.com/grpc/grpc/archive/v1.30.0.tar.gz",
    ],
    # 用来消除前缀目录
    strip_prefix = "grpc-1.30.0",
)

load("@com_github_grpc_grpc//bazel:grpc_deps.bzl", "grpc_deps")

grpc_deps()

http_archive(
    name = "com_google_protobuf",
    strip_prefix = "protobuf-3.12.3",
    # Protocol Buffer 是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,适用于RPC数据交换格式
    urls = ["https://github.com/protocolbuffers/protobuf/archive/v3.12.3.tar.gz"],
)

6.2 BUILD 文件

BUILD 文件用于告诉 Bazel 怎么构建项目,如果工作区中的一个目录包含 BUILD 文件,那么它就是一个 package。一个 BUILD 文件包含了几种不同类型的指令。其中最重要的是编译指令,用于指导 Bazel 编译。BUILD 文件中的每一条编译指令被称为一个 target,它指向一系列的源文件和依赖,一个 target 也可以指向别的 target。

(1)客户端的 BUILD

cc_binary(
    # target 的名称
    name = "user_client",
    # 源文件
    srcs = ["user_client.cpp",
            "user_client.hpp",
            "user.grpc.pb.cc",
            "user.pb.cc",
            "user.grpc.pb.h",
            "user.pb.h"],
    deps = ["@com_github_grpc_grpc//:grpc++", "@com_github_grpc_grpc//:grpc_plugin_support"]
)

(2)服务端的 BUILD

cc_binary(
    name = "user_server",
    srcs = ["user.server.hpp",
            "user_server.cpp",
            "user.grpc.pb.cc",
            "user.pb.cc",
            "user.grpc.pb.h",
            "user.pb.h"],
	includes = ["grpc/", "./"],
    deps = ["@com_github_grpc_grpc//:grpc++",
            "@com_github_grpc_grpc//:grpc_plugin_support",
            "@mysql_connector//:mysql_connector"]
)

6.3 编译项目

首先进入到 user 目录下,执行一下命令

编译客户端

bazel build //main:user_client

编译服务端

bazel build //main:user_server

注意target中的//main:是BUILD文件相对于WORKSPACE文件的位置,user-client 和 user-server 则是 BUILD 文件中命名好的 target 的名字。

一切都看似很正常,突然,bug 来了

 error loading package '@com_github_grpc_grpc//': in /private/var/tmp/_bazel_vincentwen/617a9793a26c68f1cfb352d1cc00ccbb/external/com_github_grpc_grpc/bazel/grpc_build_system.bzl: 

一个低调的单终端登录系统_第7张图片一个低调的单终端登录系统_第8张图片

  • 预测是网络问题,导致包没下好,清除该缓存目录,重新编译,重复循环多次。只有一次成功,所以真的是包的问题。
  • WORKSPACE 和 BUILD 还没理解透
  • 部署到 docker 去编译,非常好,依旧编译失败       
  • 把手动把包下到本地,再用本地库引入项目,再编译,失败
  • 更新 bazel 再编译,brew upgrade bazel,再编译,失败
  • @com_github_grpc_grpc 定义在 bazel:grpc_deps.bzl 里
  • 后来我遇到了 bazel build,虽然依旧编译失败   Linux开发环境学习--bazel+gtest在c++项目中的使用

其中"…"表示编译该项目中的所有模块,包括依赖项目。

就有缘再来解决你了。

一个低调的单终端登录系统_第9张图片

6.4 Mac 生成项目树形结构图

# 安装 tree
brew install tree

# 安装后在文件夹内执行
tree

7 Docker Compose 部署项目

Compose 是用于定义和运行多容器 Docker 应用程序的工具。通过 Compose,可以使用 YML 文件来配置应用程序需要的所有服务。然后,使用一个命令,就可以从 YML 文件配置中创建并启动所有服务。

Compose 使用的三个步骤:

  • 使用 Dockerfile 定义应用程序的环境。

  • 使用 docker-compose.yml 定义构成应用程序的服务,这样它们可以在隔离环境中一起运行。

  • 最后,执行 docker-compose up 命令来启动并运行整个应用程序。

7.1 服务端 user_server 镜像

Dockerfile 文件

FROM ubuntu

ADD . /user_server
WORKDIR /user_server

RUN set -e; \
  apt-get update; \
  apt-get install -y curl gnupg; \
  echo "deb [arch=amd64] https://storage.googleapis.com/bazel-apt stable jdk1.8" | tee /etc/apt/sources.list.d/bazel.list \
  curl https://bazel.build/bazel-release.pub.gpg | apt-key add -; \
  apt-get update; \
  apt-get install -y bazel; \
  apt-get install --only-upgrade -y bazel; \
  bazel build ...; \
  apt-get update; \
  apt-get install build-essential;

7.2  mysql 镜像

Dockerfile 文件

FROM mysql

# ARGS
ARG CHANGE_SOURCE=false

# Change Timezone
ARG TIME_ZONE=UTC
ENV TIME_ZONE ${TIME_ZONE}
RUN ln -snf /usr/share/zoneinfo/$TIME_ZONE /etc/localtime && echo $TIME_ZONE > /etc/timezone


RUN  apt-get -o Acquire::Check-Valid-Until=false update && apt-get -y upgrade

# Install Base Components
RUN apt-get install -y --no-install-recommends cron vim curl

 7.3 docker-compose.yml

version: '3'
services:
  ### user server container #########################################
  server:
      build: ./grpc/server
      ports:
        - "50051:50051"
      depends_on:
        - mysql
      links:
        - mysql:mymysql
      restart: always
      networks:
        - default

  ### Mysql container #########################################

  mysql:
      build: ./mysql
      ports:
        - "3306:3306"
      volumes:
        - ./mysql/mysql.cnf:/etc/mysql/conf.d/mysql.cnf
      privileged: true
      environment:
        - MYSQL_DATABASE=user
        - MYSQL_USER=root
        - MYSQL_PASSWORD=123456
        - MYSQL_ROOT_PASSWORD=123456
      restart: always
      networks:
        - default

8 项目的启动

8.1 启动服务端

通过 docker compose 启动

cd user
docker-compose up --build

8.2 启动客户端

通过 bazel 编译运行

cd user

bazel build ... 

bazel-bin/main/user_client

9 复盘

正确流程:

1 用 bazel 编译 grpc 的 helloworld 项目,生成可执行文件

2 为 helloworld 的服务端创建镜像,部署到 docker,创建相应的容器

3 docker 启动 helloworld 的服务端容器,执行可执行文件

4  本地启动 helloworld 的客户端

上面调通后,开始加入注册登录逻辑,再一步步调试

踩坑
1 grpc 下载慢,依赖库过于庞大。bazel 编译 grpc项目不通过,占用了很久的时间,导致代码逻辑思考时间很少。

2 概念不清晰,要先弄清楚配置相互间的关系,便于改错,如 WORKSPACE 和 BUILD 还没理解透

3 本地编译失败后,部署到 docker 里编译,依旧编译失败

4 网络得好,不然疯狂踩坑

 

 

 

 

 

 

你可能感兴趣的:(Web)