发布于 2026-01-06 4 阅读
0

使用 Rust 和 Protobuf 实现你自己的身份验证机制

使用 Rust 和 Protobuf 实现你自己的身份验证机制

介绍

开始之前,深呼吸,冥想五分钟,并准备一杯饮料,因为本教程会用到 Rust 代码。代码并不复杂,但确实是 Rust 代码。

但首先,我们来谈谈 Protobuf,它是什么?

1. 什么是 Protobuf?

根据文档:

“Protocol Buffers 是一种语言无关、平台无关的可扩展机制,用于序列化结构化数据。”

JSON 的优势在于其灵活性,当您想通过服务共享数据时,无需事先了解其结构即可解码 JSON。
但它本身是非结构化的,会占用大量空间和带宽。

使用 Protocol Buffers,您可以定义消息及其结构,服务器和客户端都必须知道该消息才能对其进行编码和解码。

// user.proto

syntax = "proto3";

message User {
    string firstname = 1;
    string lastname = 2;
    string email = 3;
}
Enter fullscreen mode Exit fullscreen mode

然后,您可以使用生成器为您喜爱的语言创建 SDK。例如,您可以为前端生成 JavaScript SDK,为后端生成 Rust SDK。

如果您使用远程过程调用 (RPC) 协议,例如 gRPC,则可以利用 Protobuf 及其生成器的功能,自动为客户端和服务端 SDK 生成接口和代码。
接下来,您只需实现服务的方法即可。

// user.proto

syntax = "proto3";

message LoginRequest {
    string email = 1;
    string password = 2;
}

service Auth {
    rpc Login (LoginRequest) returns (User);
}
Enter fullscreen mode Exit fullscreen mode

2. 我们将要实现什么目标

  1. 使用 Docker 和 Docker Compose 创建 PostgreSQL 数据库
  2. 创建 Protobuf 定义并使用 Buf 生成 SDK。
  3. 使用 Tonic 在 Rust 中搭建 gRPC 服务器
  4. 使用 Diesel and Tonic 创建一个 JWT 身份验证系统

3. 要求

你必须对 Rust 有基本的了解,我不会深入讲解如何编写 Rust 代码,因为我自己也是 Rust 新手。但我建议你查阅各个 crate 的文档,以便更好地理解它们的工作原理。

您必须了解JWT 的工作原理,因为我不会在这篇博文中解释它。

您必须安装所有必需的工具:

如果您在某个地方迷路了,或者您想直接查看代码,该存储库可在此处获取:https://github.com/kerwanp/rust-proto-demo

开始使用

1. 创建 PostgreSQL 数据库

由于我们需要持久保存用户数据,因此需要一个数据库。我们将使用PostgreSQL。

为了方便使用,我们将使用 Docker Compose 进行管理。请确保您已事先安装Docker

在根文件夹中添加以下docker-compose.yml文件:

# docker-compose.yaml

version: "3"

services:
  db:
    image: postgres
    restart: always
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: rustproto
      POSTGRES_PASSWORD: rustproto
Enter fullscreen mode Exit fullscreen mode

然后您可以运行该命令docker compose up -d来启动您的数据库(-d将在后台模式下启动)。

2. 创建 Protobuf 定义

Protobuf 定义允许两件事:

  • 定义 gRPC 服务器和客户端如何通信
  • 使用 Protobuf 生成器生成 SDK

我们将把它们存储在一个名为“proto

创建身份验证服务 Protobuf 定义

我们的服务需要两种方法:

  • Register:创建新用户。它返回一个Token
  • LoginToken:如果凭证有效,则生成一个新的。
// proto/auth.proto

syntax = "proto3";

package auth;

message LoginRequest {
    string username = 1;
    string password = 2;
}

message RegisterRequest {
    string firstname = 1;
    string lastname = 2;
    string email = 3;
    string password = 4;
}

message Token {
    string access_token = 1;
}

service Auth {
    rpc Login (LoginRequest) returns (Token);
    rpc Register (RegisterRequest) returns (Token);
}
Enter fullscreen mode Exit fullscreen mode

创建问候服务定义

为了测试我们的身份验证是否有效,我们需要一个随机服务,如果访问令牌无效,该服务将抛出未认证状态。

// proto/greeting.proto

syntax = "proto3";

package greeting;

message GreetRequest {
    string message = 1;
}

message GreetResponse {
    string message = 1;
}

service Greeting {
    rpc Greet (GreetRequest) returns (GreetResponse);
}
Enter fullscreen mode Exit fullscreen mode

3. 设置缓冲区以生成 SDK

我们有多种生成 SDK 的解决方案:

前两个解决方案非常合适,但由于我们希望对身份验证系统进行过度设计,并考虑未来可能出现的情况,我们将使用Buf CLI

所以一定要安装它

创建模块配置

Buf CLI 与工作区和模块配合使用,可以轻松拆分我们的 protobuf 定义(按 API、微服务等)。

让我们创建一个包含以下代码的文件,将我们的proto文件夹设为我们的独立模块:buf.yaml

# proto/buf.yaml

version: v1
Enter fullscreen mode Exit fullscreen mode

创建工作区配置

现在我们需要配置 Buf 工作区,方法是buf.work.yaml在项目根目录创建一个文件:

# buf.work.yaml

version: v1
directories:
  - proto # <- We define our module in the workspace
Enter fullscreen mode Exit fullscreen mode

创建生成器配置

我们将使用四个发电机:

让我们使用以下命令安装它们:

$ cargo install protoc-gen-prost protoc-gen-prost-serde protoc-gen-tonic protoc-gen-prost-crate
Enter fullscreen mode Exit fullscreen mode

然后,创建buf.gen.yaml用于配置 SDK 生成的文件:

# buf.gen.yaml

version: v1
plugins:
  - plugin: prost # Generates the core code
    out: gen/src
    opt:
      - bytes=.
      - compile_well_known_types
      - extern_path=.google.protobuf=::pbjson_types
      - file_descriptor_set
  - plugin: prost-serde # Generates code compatible with JSON serde
    out: gen/src
  - plugin: tonic # Generates the Tonic services
    out: gen/src
    opt:
      - compile_well_known_types
      - extern_path=.google.protobuf=::pbjson_types
  - plugin: prost-crate # Makes the gen folder a crate
    out: gen
    opt:
      - gen_crate=gen/Cargo.toml
Enter fullscreen mode Exit fullscreen mode

现在我们需要创建Cargo.toml一个将用作模板来生成新文件的实例。

为了简单起见,我们将使用生成的文件作为模板,以便可以进行替换。

# gen/Cargo.toml

[package]
name = "protos"
version = "0.1.0"
edition = "2021"

[features]
default = ["proto_full"]
# @@protoc_deletion_point(features)
# This section is automatically generated by protoc-gen-prost-crate.
# Changes in this area may be lost on regeneration.
# @@protoc_insertion_point(features)

[dependencies]
bytes = "1.1.0"
prost = "0.12"
pbjson = "0.6"
pbjson-types = "0.6"
serde = "1.0"
tonic = { version = "0.10", features = ["gzip"] }
Enter fullscreen mode Exit fullscreen mode

现在我们可以使用命令生成我们的 crate buf generate

🎉 我们的SDK已经准备就绪!

4. 设置 Rust 项目

现在我们有了SDK,就可以开始构建服务器的核心部分了。
首先创建一个Cargo.toml文件,并将我们的生成crate以及该项目所需的所有其他依赖项添加到依赖项列表中。

[package]
name = "proto-auth-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
diesel = { version = "2.1.3", features = ["postgres"] }
dotenvy = "0.15.7"
prost = "0.12.1"
protobuf = "3.3.0"
serde = "1.0.190"
tokio = { version = "1.33.0", features = ["full"] }
tonic = "0.10.2"
protos = { path = "./gen" }
bcrypt = "0.15.0"
jwt = "0.16.0"
hmac = "0.12.1"
sha2 = "0.10.8"

[workspace]
members = [
    "gen"
]
Enter fullscreen mode Exit fullscreen mode

我们需要两个环境变量:

  • DATABASE_URL:对我们的 PostgreSQL 数据库进行身份验证
  • APP_KEY:用于加密用户密码

将它们存储在一个.env文件中,我们稍后会使用dotenvy crate来使用它们。

# .env

DATABASE_URL=postgres://rustproto:rustproto@localhost/rustproto
APP_KEY="9E3CnfSfsi9BGfX3Dea#tkbs#nDj&6d#6Y&jhNa!"
Enter fullscreen mode Exit fullscreen mode

现在我们可以创建main.rs文件了:

// src/main.rs

use dotenvy::dotenv;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    dotenv().ok();
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

并使用以下方式构建我们的项目cargo build

5. 使用 Diesel 管理我们的用户

设置柴油

首先使用以下命令安装Diesel CLI :

$ cargo install diesel_cli
Enter fullscreen mode Exit fullscreen mode

您可能需要安装libpq-devlibmysqlclient-devlibsqlite3-dev安装 CLI。

创建迁移文件夹:

$ diesel setup
Enter fullscreen mode Exit fullscreen mode

并在你的文件顶部添加以下几行代码main.rs(我们稍后会创建这些模块):

// src/main.rs

mod models;
mod schema;
Enter fullscreen mode Exit fullscreen mode

创建迁移

我们先生成创建和删除users表的迁移脚本。

$ diesel migration generate create_users
Enter fullscreen mode Exit fullscreen mode
-- migrations/*-create_users/up.sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    firstname VARCHAR(255) NOT NULL,
    lastname VARCHAR(255) NOT NULL
);
Enter fullscreen mode Exit fullscreen mode
-- migrations/*-create_users/down.sql
DROP TABLE users;
Enter fullscreen mode Exit fullscreen mode

运行以下命令来执行迁移并生成schema.rs文件:

$ diesel migration run
Enter fullscreen mode Exit fullscreen mode

创建用户模型

创建一个新文件src/models.rs,并将我们的 User 模型添加到其中,并实现创建新用户和通过电子邮件查找用户的方法。

// src/models.rs

use diesel::{
    ExpressionMethods, Insertable, PgConnection, QueryDsl, Queryable, RunQueryDsl, Selectable,
    SelectableHelper,
};

use crate::schema::users;

#[derive(Queryable, Selectable)]
#[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct User {
    pub id: i32,
    pub firstname: String,
    pub lastname: String,
    pub email: String,
    pub password: String,
}

#[derive(Insertable)]
#[diesel(table_name = users)]
pub struct NewUser<'a> {
    pub firstname: &'a str,
    pub lastname: &'a str,
    pub email: &'a str,
    pub password: &'a str,
}

impl User {
    pub fn create(
        conn: &mut PgConnection,
        firstname: &str,
        lastname: &str,
        email: &str,
        password: &str,
    ) -> Result<User, diesel::result::Error> {
        let new_user = NewUser {
            firstname,
            lastname,
            email,
            password,
        };

        diesel::insert_into(users::table)
            .values(new_user)
            .returning(User::as_returning())
            .get_result(conn)
    }

    pub fn find_by_email(conn: &mut PgConnection, email: &str) -> Option<User> {
        users::dsl::users
            .select(User::as_select())
            .filter(users::dsl::email.eq(email))
            .first(conn)
            .ok()
    }
}
Enter fullscreen mode Exit fullscreen mode

创建数据库连接

现在我们需要设置数据库连接,完成 Diesel 部分就完成了。

// src/main.rs

use std::env;

use diesel::{PgConnection, Connection};
use dotenvy::dotenv;

mod models;
mod schema;

pub fn connect_db() -> PgConnection {
    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    PgConnection::establish(&database_url)
        .unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    dotenv().ok();

    let mut database = connect_db();

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

您可以通过使用数据库连接创建用户来尝试一下。User.create(&mut database, "", "", "", "")

6. 实施东京服务

现在是时候为我们的身份验证系统创建业务逻辑了。
我们将从最重要的部分——身份验证开始。

创建骨架

use std::sync::{Arc, Mutex};

use diesel::PgConnection;
use protos::auth::{auth_server::Auth, LoginRequest, Token, RegisterRequest};
use tonic::{Request, Response, Status};

use crate::models::User;

pub struct Service {
    database: Arc<Mutex<PgConnection>>,
}

impl Service {
    pub fn new(database: PgConnection) -> Self {
        Self {
            database: Arc::new(Mutex::new(database))
        }
    }

    fn generate_token(user: User) -> Token {
        unimplemented!();
    }
}

#[tonic::async_trait]
impl Auth for Service {
    async fn login(&self, request: Request<LoginRequest>) -> Result<Response<Token>, Status> {
        unimplemented!();
    }

    async fn register(&self, request: Request<RegisterRequest>) -> Result<Response<Token>, Status> {
        unimplemented!();
    }
}
Enter fullscreen mode Exit fullscreen mode

启动 Tonic 服务器并添加我们尚未实现的服务

main.rs文件中,我们将创建一个服务器,将我们的服务添加到该服务器并启动它。

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    dotenv().ok();

    let database = connect_db();
    let addr = "[::1]:50051".parse()?;

    Server::builder()
        .add_service(AuthServer::new(auth::Service::new(database)))
        .serve(addr)
        .await?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

您可以使用以下命令调用auth.Auth/Login函数来测试您的服务器(package.Service/Method)。

$ grpcurl -plaintext -import-path ./proto -proto auth.proto '[::1]:50051' auth.Auth/Login
Enter fullscreen mode Exit fullscreen mode

您应该在服务器控制台中看到以下错误:

thread 'tokio-runtime-worker' panicked at 'not implemented', src/auth.rs:22:9
Enter fullscreen mode Exit fullscreen mode

这个错误是由我们的身份验证服务的宏抛出的unimplemented!,它运行正常!

生成访问令牌

我们将使用jwt crate创建一个 JWT 令牌。它将包含以下声明:

  • subJWT 的主题(我们的用户 ID)
  • iat签发JWT的时间
  • expJWT 过期时间

实现寄存器方法

注册方法相当简单,它直接接收请求消息,并在加密密码后在我们的数据库中创建条目。

// src/auth.rs

async fn register(&self, request: Request<RegisterRequest>) -> Result<Response<Token>, Status> {
    let database = self.database.lock();
    let data = request.into_inner();

    let password = bcrypt::hash(&data.password, 10)
        .map_err(|_| Status::unknown("Error while creating the user"))?;

    let user = NewUser {
        firstname: &data.firstname,
        lastname: &data.lastname,
        email: &data.email,
        password: &password,
    };

    let user = User::create(&mut database.unwrap(), user);

    unimplemented!();
}
Enter fullscreen mode Exit fullscreen mode

你现在就可以试试!它会报错,unimplemented但你的用户应该已经在数据库中创建了。

实现生成令牌的方法

我们将把函数分成两部分,一部分用于生成声明,另一部分用于对令牌进行编码。

我们将使用文件APP_KEY中定义的方法.env来签署 JWT。

// src/auth.rs
pub struct GenerateTokenError;

pub struct GenerateClaimsError;

fn generate_claims(user: User) -> Result<BTreeMap<&'static str, String>, GenerateClaimsError> {
    let mut claims: BTreeMap<&str, String> = BTreeMap::new();

    claims.insert("sub", user.id.to_string());

    let current_timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|_| GenerateClaimsError)?
        .as_secs();

    claims.insert("iat", current_timestamp.to_string());
    claims.insert("exp", String::from("3600"));

    Ok(claims)
}

fn generate_token(user: User) -> Result<Token, GenerateTokenError> {
    let app_key: String = env::var("APP_KEY").expect("env APP_KEY is not defined");

    let key: Hmac<Sha256> =
        Hmac::new_from_slice(app_key.as_bytes()).map_err(|_| GenerateTokenError)?;

    let claims = generate_claims(user).map_err(|_| GenerateTokenError)?;

    let access_token = claims.sign_with_key(&key).map_err(|_| GenerateTokenError)?;

    Ok(Token {
        access_token: access_token,
    })
}

Enter fullscreen mode Exit fullscreen mode

现在,让我们使用这个函数在用户注册时返回一个访问令牌:

// src/auth.Rs
impl Auth for Service {
    [...]
    async fn register(&self, request: Request<RegisterRequest>) -> Result<Response<Token>, Status> {
        let database = self.database.lock();
        let data = request.into_inner();

        let password = bcrypt::hash(&data.password, 10)
            .map_err(|_| Status::unknown("Error while creating the user"))?;

        let user = NewUser {
            firstname: &data.firstname,
            lastname: &data.lastname,
            email: &data.email,
            password: &password,
        };

        let user = User::create(&mut database.unwrap(), user)
            .map_err(|_| Status::already_exists("User already exists in the database"))?;

        let token = generate_token(user).map_err(|_| Status::unknown("Cannot generate a token for the User"))?;

        Ok(Response::new(token))
    }
}
Enter fullscreen mode Exit fullscreen mode

您可以使用以下命令创建新用户:

$ grpcurl -plaintext -import-path ./proto -proto auth.proto -d '{"firstname": "John", "lastname": "Doe", "email": "john@doe.com", "password": "rustproto"}' '[::1]:50051' auth.Auth/Register
Enter fullscreen mode Exit fullscreen mode

如果你再次运行它,就会出现错误AlreadyExists。🎉

实现登录方法

现在的登录方法非常简单,我们尝试查找与邮件中的电子邮件对应的用户,验证密码是否正确,并使用该generate_token方法返回响应。

// src/auth.rs
impl Auth for Service {
    [...]
    async fn login(&self, request: Request<LoginRequest>) -> Result<Response<Token>, Status> {
        let data = request.into_inner();

        let database = self.database.lock();

        let user = User::find_by_email(&mut database.unwrap(), &data.email)
            .ok_or(Status::unauthenticated("Invalid email or password"))?;

        match verify(data.password, &user.password) {
            Ok(true) => (),
            Ok(false) | Err(_) => return Err(Status::unauthenticated("Invalid email or password")),
        };

        let reply = generate_token(user)
            .map_err(|_| Status::unauthenticated("Invalid email or password"))?;

        Ok(Response::new(reply))
    }
    [...]
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以使用之前注册用户的电子邮件地址和密码生成令牌了!

$ grpcurl -plaintext -import-path ./proto -proto auth.proto -d '{"email": "john@doe.com", "password": "rustproto"}' '[::1]:50051' auth.Auth/Login
Enter fullscreen mode Exit fullscreen mode

实现验证令牌方法

由于这篇文章已经很长了,我们将简化验证过程,只检查签名,如果签名有效则返回 true。

// src/auth.rs
pub struct VerifyTokenError;

pub fn verify_token(token: &str) -> Result<bool, VerifyTokenError> {
    let app_key: String = env::var("APP_KEY").expect("env APP_KEY is not defined");

    let key: Hmac<Sha256> =
        Hmac::new_from_slice(app_key.as_bytes()).map_err(|_| VerifyTokenError)?;

    Ok(token
        .verify_with_key(&key)
        .map(|_: HashMap<String, String>| true)
        .unwrap_or(false))
}
Enter fullscreen mode Exit fullscreen mode

实现问候服务

在新src/greeting.rs文件中,我们将使用生成的 SDK 提供的 trait 来实现我们的 Greeting 服务。

x-authorization我们将验证元数据中是否存在令牌,并验证其值。

// src/greeting.rs

use protos::greeting::{greeting_server::Greeting, GreetRequest, GreetResponse};
use tonic::{Request, Response, Status};

use crate::auth;

#[derive(Default)]
pub struct Service {}

#[tonic::async_trait]
impl Greeting for Service {
    async fn greet(
        &self,
        request: Request<GreetRequest>,
    ) -> Result<Response<GreetResponse>, Status> {
        let token = request
            .metadata()
            .get("x-authorization")
            .ok_or(Status::unauthenticated("No access token specified"))?
            .to_str()
            .map_err(|_| Status::unauthenticated("No access token specified"))?;

        match auth::verify_token(token) {
            Ok(true) => (),
            Err(_) | Ok(false) => return Err(Status::unauthenticated("Invalid token")),
        }

        let data = request.into_inner();

        Ok(Response::new(GreetResponse {
            message: format!("{} {}", data.message, "Pong!"),
        }))
    }
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以将我们的服务添加到 Tonic 服务器上main.rs

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    dotenv().ok();

    let database = connect_db();
    let addr = "[::1]:50051".parse()?;

    Server::builder()
        .add_service(AuthServer::new(auth::Service::new(database)))
        .add_service(GreetingServer::new(greeting::Service::default()))
        .serve(addr)
        .await?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

现在是时候测试我们的身份验证系统了!您可以使用以下命令,并将访问令牌替换为通过登录或注册方法生成的令牌:

$ grpcurl -plaintext -import-path ./proto -proto greeting.proto -H 'x-authorization: <access_token>' -d '{"message": "Ping!" }' '[::1]:50051' greeting.Greeting/Greet
Enter fullscreen mode Exit fullscreen mode

如果尝试输入错误的访问令牌,您将收到未认证状态!

你成功了🚀

Rust 和 Protobuf 对我来说都是新的,但这正是那种能帮助我学习陡峭学习曲线的事物并获得良好学习体验的项目,我希望它也能帮助你更好地理解 Rust、Protobuf 和 gRPC。

您可以在这里找到代码仓库:https://github.com/kerwanp/rust-proto-demo

在下一篇博客文章中,我们将学习如何在 NextJS 中使用此 API,所以请务必关注dev.to/martinp和 Twitter @PaucotMartin ,以便及时了解最新动态。

如果你遇到任何问题、建议或意见,欢迎在评论区留言!

文章来源:https://dev.to/martinp/roll-your-own-auth-with-rust-and-protobuf-24ke