一個低調的單終端登錄系統

嘗試在一週內學習 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. 創建 3 個鏡像,服務端 user_server、 mysql 和 redis,分別基於 user_server 鏡像 、mysq 鏡像和 redis 鏡像啓動容器
  6. 通過 bazel 編譯啓動客戶端 user_client
  7. docker compose 管理容器

然後現實總是很殘酷,grpc 下載慢(多下幾次),編譯源碼蹦出來各種坑(主要是依賴庫沒下好)。

嘴上一直說着不弄了,還好身體很誠實,一直坐着敲敲,終於整出了一個振奮人心的 “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 'MD5(MD5(password明文+固定salt) + 隨機salt)',
  `salt` varchar(10) DEFAULT NULL COMMENT '隨機鹽值,全局唯一',
  `device` varchar(255) NOT NULL COMMENT '設備號',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3 安全性設計

參考:常見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.1 明文密碼兩次MD5處理

(1) 客戶端(第一次MD5)

用戶登錄時輸入明文密碼,生成一個固定 Salt,與該密碼拼裝,目的是用於混淆密碼。加固定鹽效果當然不如隨機鹽,但總比沒鹽好。然後進行第一次 MD5 處理,傳輸給服務器端。

password = MD5(明文密碼 + 固定Salt)

第一次 MD5 防止明文密碼在網絡傳輸時被盜。

String salt = "1a2b3c4d"
//明文密碼 + 固定Salt
String str = ""+salt.charAt(0)+salt.charAt(2) + inputPass +salt.charAt(5) + salt.charAt(4);
//客戶進行第一次MD5處理
String password = md5(str);

(2)服務端(第二次MD5)

服務器端接收到進行了第一次 MD5 處理後的密碼時,要生成一個隨機的 Salt,與密碼進行拼裝後再進行第二次 MD5,最後才寫入數據庫。隨機 Salt 要單獨存入數據表。

PASS = MD5(密碼 + 隨機Salt)

第二次 MD5 防止數據庫被入侵時,密碼被反查。

//第二次MD5,密碼+隨機Salt
String str = ""+salt.charAt(0)+salt.charAt(2) + formPass +salt.charAt(5) + salt.charAt(4);

4 單終端登錄控制

分佈式 session

SessionID 是容器生成的,多臺 應用服務器 就會有多個 SessionID,所以 SessionID 不易應用於解決分佈式 Session 共享問題。該項目決定使用全局唯一 id 作爲 token 來解決分佈式 session 問題。

用戶登錄

當用戶登錄成功時,服務端根據用戶的手機號 mobile 和設備號 device 生成一個 uuid,然後以 mobile 作爲 key,uuid 作爲 value 存入分佈式 redis;以 uuid 作爲 key, session 作爲 value 存入分佈式 redis。用戶每次登錄先通過 mobile 查詢 redis,如果能獲取到 uuid 且 uuid 不等於當前用戶的 uuid,要啓動強制下線,並下發下線通知,然後將當前用戶的 uuid 與 session 覆蓋上一次的值,保存到 redis。

強制下線通知

通過 gRPC 框架的 stream 機制來實現下線通知的推送。

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 mobile = 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 SignIn(ServerContext* context, const UserRequest* request,
                  UserReply* reply) override {
        UserDB userDb;
        ServerResult result;
        // 根據手機號獲取 salt
        String salt = userBb.getSalt(mobile);
        // salt 與 第一次加密後的密碼 進行二次加密
        std::string salt = Encrypt::randomSalt();
        std::string pwdHash = Encrypt::md5(request->password() + salt);
        User user userDb->getUser(request->mobile(),pwdHash);
        if(user == null){
            result->setErr(1);
            result->sermessage("user is not exit, please sign up");
        }
        // 根據 mobie 生成 uuid
        string uuid = EncryptUtil->getTokenForUser(mobile);
        // 以 mobile 作爲 key,uuid 作爲 value 放入 redis,採用 string 數據類型
        // 以 uuid 作爲 key, session 作爲 value 放入 redis,採用 hash 數據類型
}

ServerResult SignIn(ServerContext* context, const UserRequest* request,
                  UserReply* reply) override {
        UserDB userDb;
        ServerResult result;
        // 根據手機號獲取 salt
        String salt = userBb.getSalt(mobile);
        // salt 與 第一次加密後的密碼 進行二次加密
        std::string salt = Encrypt::randomSalt();
        std::string pwdHash = Encrypt::md5(request->password() + salt);
        User user userDb->getUser(request->mobile(),pwdHash);
        if(user == null){
            result->setErr(1);
            result->sermessage("user is not exit, please sign up");
        }
        // 根據 mobie 生成 uuid
        string uuid = EncryptUtil->getTokenForUser(mobile);
        // 通過 mobile 查詢 redis
        string old_uuid = redisString.get(mobile);
        if(old_uuid != null && old_uuid != uuid){
            // 啓動下線通知
        }
        // 以 mobile 作爲 key,uuid 作爲 value 放入 redis,採用 string 數據類型
        // 以 uuid 作爲 key, session 作爲 value 放入 redis,採用 hash 數據類型
}

6 Bazel 構建項目

Bazel的編譯基於工作區(workspace)。工作區是一個存放了所有源代碼和 Bazel 編譯輸出文件的目錄,是整個項目的根目錄。指定一個目錄爲 Bazel 的工作區,就只要在該目錄下創建一個空的WORKSPACE文件即可。

Mac 生成項目樹形結構圖

# 安裝 tree
brew install tree

# 安裝後在文件夾內執行
tree
.
├── docker-compose.yml     
├── grpc    
│   ├── Dockerfile
│   ├── WORKSPACE
│   ├── client                          // 客戶端代碼
│   │   ├── src
│   │   │   ├── BUILD
│   │   │   ├── user_client.cpp
│   │   │   ├── user_client.hpp
│   │   │   └── utils
│   │   │       ├── EncryptUtil.cpp
│   │   │       ├── EncryptUtil.hpp
│   │   │       ├── MD5Util.cpp
│   │   │       └── MD5Util.hpp
│   │   ├── user.grpc.pb.cc
│   │   ├── user.grpc.pb.h
│   │   ├── user.pb.cc
│   │   └── user.pb.h
│   ├── protos                         // gRPC接口定義
│   │   └── user.proto
│   └── server                         // 服務端代碼
│       ├── Dockerfile
│       ├── src
│       │   ├── BUILD
│       │   ├── result
│       │   │   ├── ServerResult.cpp
│       │   │   └── ServerResult.hpp
│       │   ├── user_server.cpp
│       │   ├── user_server.hpp
│       │   └── utils
│       │       ├── EncryptUtil.cpp
│       │       ├── EncryptUtil.hpp
│       │       ├── MD5Util.cpp
│       │       └── MD5Util.hpp
│       ├── test                      
│       │   ├── BUILD
│       │   ├── serverTest.cpp
│       │   └── serverTest.hpp
│       ├── user.grpc.pb.cc
│       ├── user.grpc.pb.h
│       ├── user.pb.cc
│       └── user.pb.h
├── mysql
│   ├── Dockerfile
│   ├── UserDB.cpp
│   ├── UserDB.hpp
│   ├── init.sql
│   └── mysql.cnf
├── redis
     ├── Dockerfile
     └── redis.conf

生成項目依賴圖

bazel query --nohost_deps --noimplicit_deps 'deps(//client/src:user-client)' \
  --output graph

bazel query --nohost_deps --noimplicit_deps 'deps(//server/src:user-server)' \
  --output graph

報錯了:找不到依賴包

        

6.1 WORKSPACE 文件

 WORKSPACE 文件  用於指定當前文件夾就是一個 Bazel 的工作區,所以 WORKSPACE 文件總是存在於項目的根目錄下。

在 WORKSPACE 中引入外部依賴

本項目的 WORKSPACE 文件

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。

舉個例子,下面這個hello-world的target利用了Bazel內置的cc_binary編譯指令,來從hello-world.cc源文件(沒有其他依賴項)構建一個可執行二進制文件。指令裏面有些屬性是強制的,比如name,有些屬性則是可選的,srcs表示的是源文件。

(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 //client:user-client

編譯服務端

bazel build //server:user-server

注意target中的//server:是BUILD文件相對於WORKSPACE文件的位置,user-client 和 user-server 則是 BUILD 文件中命名好的 target 的名字。

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 src:user_server; \
  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 redis 鏡像

Dockerfile 文件

FROM redis

# 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

CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ]

 7.4 docker-compose.yml

version: '3'
services:
  ### user server container #########################################
  server:
      build: ./grpc/server
      ports:
        - "50051:50051"
      depends_on:
        - mysql
        - redis
      links:
        - mysql:mymysql
        - redis:myredis
      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

  ### Redis container #########################################

  redis:
      build: ./redis
      ports:
        - "6379:6379"
      volumes:
        - ./redis/redis.conf:/usr/local/etc/redis/redis.conf
      restart: always
      networks:
        - default

8 項目的啓動

8.1 啓動服務端

通過 docker compose 啓動

cd user
docker-compose up --build

8.2 啓動客戶端

通過 bazel 編譯運行

bazel build src:user_client 

bazel-bin/src/user_client

9 總結

由於沒學過 C++ ,它語法還是讓我有點難啃,所以部分代碼只能用僞代碼去編寫。雖然程序終究沒能運行起來,但是整個項目的結構是比較清晰的,每個技術間的關係與整合也弄明白了,還是一次很有意義的挑戰。

  • 接觸新技術的時候,首先去官網看文檔,運行其最基本的例子。
  • 去 github 找一些綜合的項目,有利於弄清楚每個技術怎樣在一個項目裏整合起來的。
  • 結合相關的技術博文,繼續去看官網文檔,摳細節啦。

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章