优秀的编程知识分享平台

网站首页 > 技术文章 正文

Rust Web编程:第七章 管理用户会话

nanyue 2024-08-10 18:39:14 技术文章 6 ℃


此时,我们的应用程序正在通过单击视图上的按钮来操作适当数据库中的数据。 但是,任何使用我们应用程序的人都可以编辑数据。 虽然我们的应用程序不是需要大量安全性的应用程序类型,但它是一般 Web 开发中理解和实践的一个重要概念。

在本章中,我们将构建一个创建用户的系统。 该系统还将通过要求用户登录才能通过前端应用程序更改任何待办事项来管理用户会话。

在本章中,我们将讨论以下主题:

通过数据库迁移创建具有与某些字段的唯一约束的其他表的关系的用户数据模型

验证我们的用户

管理用户会话

清理身份验证要求

配置身份验证令牌的过期时间

将身份验证添加到我们的前端

阅读本章后,您将能够了解在 Web 服务器上验证用户身份的基础知识。 您还可以在 Rust 应用程序的服务器端实现此身份验证,并将凭据存储在前端的 React 应用程序中。 对本章所涵盖的概念和实践的理解还将使您能够使用 React Native 将身份验证合并到电话应用程序中,以及通过将我们的 React 应用程序包装在 Electron 中来将身份验证合并到 Rust 服务器和桌面应用程序中。

创建我们的用户模型

由于我们在应用程序中管理用户会话,因此在允许创建、删除和编辑待办事项之前,我们需要存储有关用户的信息以检查他们的凭据。 我们将把用户数据存储在 PostgreSQL 数据库中。 虽然这不是必需的,但我们还将数据库中的用户链接到待办事项。 这将使我们了解如何更改现有表并在表之间创建链接。 要创建我们的用户模型,我们必须执行以下操作:

创建用户数据模型。

创建 NewUser 数据模型。

更改待办事项数据模型,以便我们可以将其链接到用户模型。

使用新表和更改的字段更新架构文件。

在数据库上创建并运行迁移脚本。

在下面的部分中,我们将详细了解前面的步骤。

创建用户数据模块

在开始之前,我们需要使用以下依赖项更新 Cargo.toml 文件中的依赖项:

[dependencies]
. . .
bcrypt = "0.13.0"
uuid = {version = "1.0.0", features = ["serde", "v4"]}

我们将使用 bcrypt 箱来哈希和检查密码,并使用 uuid 箱为我们的用户数据模型生成唯一 ID。 正如我们在第 6 章“使用 PostgreSQL 进行数据持久化”中所介绍的,我们需要为用户数据模型创建两个不同的结构。

新用户不会有 id 字段,因为数据库中尚不存在该字段。 当新用户插入表中时,数据库会创建此 ID。 然后我们有另一个结构体,它具有与我们添加的 id 字段相同的字段,因为当我们与数据库中的现有用户交互时可能需要使用此 ID。 ID 号对于引用其他表很有用。 它们很短,我们知道它们是独一无二的。 我们将使用用户 ID 将用户链接到待办事项。 这些数据模型可以存放在 src/models.rs 目录中的以下文件结构中:

└── user
    ├── mod.rs
    ├── new_user.rs
    └── user.rs

我们将在 new_user.rs 文件中定义数据模型。 首先,我们必须定义导入,如下所示:

use uuid::Uuid;
use diesel::Insertable;
use bcrypt::{DEFAULT_COST, hash};
use crate::schema::users;

必须注意的是,我们还没有在模式中定义用户。 完成所有数据模型后,我们将解决这个问题。 在定义用户模式之前,我们将无法编译代码。 我们还将导入一个唯一的 ID 板crate,因为我们将在创建新用户时创建一个唯一的 ID,并导入diesel中的可插入特征,因为我们将把新用户插入到我们的数据库中。 然后,我们使用 bcrypt 箱中的哈希函数来哈希我们为新用户定义的新密码。 我们还可以看到我们从 bcrypt 箱中导入了 DEFAULT_COST 常量。 DEFAULT_COST 只是一个我们将传递给哈希函数的常量。 我们将在下一节讨论哈希密码时探讨为什么会出现这种情况。 现在我们已经定义了用户数据模型模块并导入了我们需要的内容,我们可以继续下一部分来创建 NewUser 结构。

创建 NewUser 数据模型

我们可以使用以下代码定义我们的数据模型:

#[derive(Insertable, Clone)]
#[table_name="users"]
pub struct NewUser {
    pub username: String,
    pub email: String,
    pub password: String,
    pub unique_id: String,
}

在这里,我们可以看到我们允许数据模型可插入。 但是,我们不允许对其进行查询。 我们希望确保从数据库检索用户时,他们的 ID 存在。 我们可以继续为用户定义通用数据模型,但这并不安全。 我们需要通过散列来确保我们的密码受到保护。 如果您还记得第 2 章“用 Rust 设计 Web 应用程序”,我们利用特征来允许某些待办事项结构执行操作。 一些结构可以创建,而另一些结构可以根据它们实现的特征进行删除。 我们通过实现 Insertable 特征来锁定 NewUser 结构的功能。 但是,我们将通过实现 User 结构的其他特征来启用查询,如下图所示:



现在我们已经创建了将新用户插入数据库的结构,我们可以探索如何在数据库中存储用户的密码。

您可能想知道为什么无法恢复忘记的密码; 您只能重置它们。 这是因为密码经过哈希处理。 在存储密码时,对密码进行哈希处理是一种常见的做法。 在这里,我们使用算法来混淆密码,使其无法被读取。 一旦完成,就无法逆转。

然后,哈希密码将存储在数据库中。 为了检查密码,将对输入密码进行哈希处理并与数据库中的哈希密码进行比较。 这使我们能够查看输入的哈希密码是否与数据库中存储的哈希密码匹配。 这有几个优点。 首先,它可以防止有权访问您数据的员工知道您的密码。 如果发生数据泄露,它还可以防止泄露的数据直接将您的密码暴露给拥有该数据的人。

考虑到很多人在多种事情上使用相同的密码(即使他们不应该这样做),你只能想象如果你不对密码进行哈希处理并且发生数据泄露,可能会对使用你的应用程序的人造成的损害。 然而,哈希变得比这更复杂。 有一个称为加盐的概念,可确保当您散列相同的密码时,不会产生相同的散列。 它通过在对密码进行哈希处理之前向密码添加额外的数据位来实现此目的。 这也是我们传递给哈希函数的 DEFAULT_COST 常量的用武之地。假设我们掌握了数据库中的数据,并且我们想要编写代码来猜测数据中的密码。 如果我们有足够的计算能力,我们可以有效地猜测密码。 因此,我们可以传入一个cost参数。 当我们增加成本参数时,CPU 时间或内存的工作量呈指数级增长。 将成本因子增加 1 将使计算哈希所需的操作数量增加 10,000 甚至更多。

更详细地解释密码安全性超出了本书的范围。 但必须强调的是,在存储密码时,密码哈希始终是必须的。 幸运的是,所有主要语言都有一系列模块,使您只需几行代码即可散列和检查密码。 Rust在这里没有什么不同。

为了确保我们可以使用散列密码将新用户插入数据库,请按照下列步骤操作:

首先,我们必须确保输入密码在 NewUser 构造函数中进行哈希处理,其定义如下:

impl NewUser {

    pub fn new(username: String,

        email: String, password: String) -> NewUser {

        let hashed_password: String = hash(

                password.as_str(), DEFAULT_COST

            ).unwrap();

        let uuid = Uuid::new_v4().to_string();

        return NewUser {

            username,

            email,

            password: hashed_password,

            unique_id: uuid

        }

    }

}

在这里,我们使用 bcrypt 箱中的哈希函数来哈希我们的密码,同时我们还传入了 DEFAULT_COST 常量。 我们还使用 Uuid 箱创建了一个唯一的 ID,然后使用这些属性构造了 NewUser 结构的新实例。 在我们的应用程序中,并不真正需要唯一的 ID。 但是,如果您在多个服务器和数据库之间进行通信,这些可能会派上用场。

现在我们已经定义了 NewUser 数据模型,我们可以使用以下代码在 user.rs 文件中定义通用用户数据模型。 首先,我们必须定义以下导入:

extern crate bcrypt;

use diesel::{Queryable, Identifiable};

use bcrypt::verify;

use crate::schema::users;

在这里,我们可以看到我们正在使用验证函数,并且我们还允许通用用户数据模型结构可查询和可识别。

使用上一步中定义的导入,我们可以构建 User 结构。 请记住,当我们进行数据库查询时,这是一个将从数据库加载的结构。 在进一步阅读之前,这是尝试自己构建 User 结构的好时机,因为它使用与 NewUser 结构相同的表,但有一个 id 字段,并且是查询而不是插入。 构建了 User 结构后,它应该类似于以下代码:

#[derive(Queryable, Clone, Identifiable)]

#[table_name="users"]

pub struct User {

    pub id: i32,

    pub username: String,

    pub email: String,

    pub password: String,

    pub unique_id: String

}

我们可以看到,我们只是添加了 id 字段并派生了 Queryable 特征,而不是 Insertable 特征。

现在我们的 User 结构体已经定义了,我们可以构建一个函数来验证输入密码是否与属于用户的密码匹配,代码如下:

impl User {

    pub fn verify(&self, password: String) -> bool {

    verify(password.as_str(),

    &self.password).unwrap()

    }

}

现在我们的模型已经定义好了,我们必须记住使用以下代码在 models/user/mod.rs 文件中注册它们:

pub mod new_user;
pub mod user;

此外,我们可以通过将以下行添加到 models/mod.rs 文件中来使这些模块可供应用程序访问:

pub mod item;
pub mod user;

这样,我们的用户数据模型就已经定义好了。 但是,我们仍然需要将它们链接到我们的待办事项。

更改待办事项数据模型

要将数据模型链接到我们的待办事项,我们必须更改待办事项数据模型。 我们可以通过多种方式来做到这一点。 例如,我们可以将 user_id 字段添加到 item 表中,该字段就是 user 表的 unique_id 字段。 当我们创建新项目时,我们将用户的唯一 ID 传递到项目构造函数中。 这很容易实现; 然而,它确实存在风险。 仅将用户的唯一 ID 传递到项目中并不能强制用户的 ID 有效且在数据库中。 没有什么可以阻止我们将已删除用户的 ID 插入到项目构造函数中,从而将孤立项目插入到数据库中。 稍后将很难提取该信息,因为我们没有引用与孤立项目关联的用户 ID。 我们还可以创建一个新表,将用户 ID 与商品 ID 引用在一起,如下图所示:



这样做的优点是,只需删除表即可轻松将用户与项目解耦。 但是,在创建新条目时,它也没有有效的用户 ID 强制或项目 ID 强制。 我们还必须进行两次查询,一次查询关联表,然后另一次查询项目表以从用户处获取项目。 由于前面两种将用户 ID 列附加到项目表或创建保存项目 ID 和用户唯一 ID 的桥表的方法很容易实现,因此我们不会探讨它们; 此时您应该能够自己实现它们。 在待办事项应用程序的上下文中,前两种方法效果不佳,因为它们没有给我们带来任何好处,但在将数据插入数据库时会带来出错的风险。 这并不意味着永远不应该使用前面的两种方法。 每个项目的数据需求都不同。 在我们的项目中,我们将创建一个外键来将用户链接到项目,如下图所示:



这不允许我们通过一次数据库调用访问与用户关联的项目,但我们只允许插入引用数据库中合法用户 ID 的项目。 外键还可以触发级联事件,如果我们删除用户,这将自动删除与该用户关联的所有现有项目,以防止创建孤立项目。 我们通过使用宏声明表的链接来创建外键。 在 models/item/item.rs 中,我们可以通过最初导入以下内容来实现此目的:

use crate::schema::to_do;
use chrono::NaiveDateTime;
use super::super::user::user::User;

我们可以看到,我们必须导入 User 结构,因为我们将在 Belongs_to 宏中引用它来声明我们的 Item 结构属于 User 结构,如以下代码所示:

#[derive(Queryable, Identifiable, Associations)]
#[belongs_to(User)]
#[table_name="to_do"]
pub struct Item {
    pub id: i32,
    pub title: String,
    pub status: String,
    pub date: NaiveDateTime,
    pub user_id: i32,
}

在这里,我们可以看到我们导入了用户数据模型结构,使用belongs_to宏定义它,并添加了一个user_id字段来链接该结构。 请注意,如果我们不包含 Associations 宏,belongs_to 宏将不可调用。

我们需要做的最后一件事是将 user_id 字段添加到 models/item/new_item.rs 文件中的字段和构造函数中。 我们需要这样做,以便我们可以将新的待办事项链接到创建该项目的用户。 这可以通过使用以下代码来实现:

use crate::schema::to_do;
use chrono::{NaiveDateTime, Utc};
#[derive(Insertable)]
#[table_name="to_do"]
pub struct NewItem {
    pub title: String,
    pub status: String,
    pub date: NaiveDateTime,
    pub user_id: i32,
}
impl NewItem {
    pub fn new(title: String, user_id: i32) -> NewItem {
        let now = Utc::now().naive_local();
        NewItem{
            title, status: String::from("PENDING"),
            date: now,
            user_id
        }
    }
}

因此,回顾我们所做的事情,我们所有的数据模型结构都已更改,并且在与数据库交互时,我们可以在应用程序中需要它们时使用它们。 但是,我们还没有更新数据库,也没有更新连接应用程序和数据库的桥。 接下来我们将这样做。

更新架构文件

为了确保从数据模型结构到数据库的映射是最新的,我们必须使用这些更改来更新我们的架构。 这意味着我们必须更改待办事项表的现有架构,并将用户架构添加到 src/schema.rs 文件中。 这由以下代码表示:

table! {
    to_do (id) {
        id -> Int4,
        title -> Varchar,
        status -> Varchar,
        date -> Timestamp,
        user_id -> Int4,
    }
}
table! {
    users (id) {
        id -> Int4,
        username -> Varchar,
        email -> Varchar,
        password -> Varchar,
        unique_id -> Varchar,
    }
}

必须注意的是,模式文件中的字段定义顺序与 Rust 数据模型相同。 这很重要,因为如果我们不这样做,当我们连接到数据库时,字段将不匹配。 我们可能还意识到我们的模式只是定义字段及其类型; 它没有涵盖待办事项表和用户表之间的关系。

我们不必担心这一点,因为当我们创建并运行自己的迁移时,该架构文件将随关系一起更新。 这导致我们创建自己的迁移来完成此架构文件。

在数据库上创建并运行迁移脚本

运行迁移的过程与我们在第 6 章“使用 PostgreSQL 进行数据持久化”中介绍的过程类似,其中介绍了如何安装 Diesel 客户端并连接到数据库。 首先,我们必须使用 docker-compose 命令运行数据库:

docker-compose up

当我们运行迁移时,我们需要在后台运行它。 然后我们可以通过运行以下命令来创建迁移脚本:

diesel migration generate create_users

这会在迁移中创建一个目录,其中包含该目录的用户名中的 create_users 。 在这个目录中,我们有两个空白的 SQL 文件。 在这里,我们将手动编写自己的 SQL 脚本来进行迁移。 最初,您可能会发现这是不必要的,因为其他语言的库可以自动生成这些迁移,但这样做有一些优点。

首先,它让我们能够继续使用 SQL,这是另一个方便的工具。 这使我们能够考虑在我们试图解决的日常问题中利用 SQL 的解决方案。 它还使我们能够更细粒度地控制迁移的流程。 例如,在我们要创建的迁移中,我们将必须创建用户表,然后创建一个基本用户,以便当我们更改 to_do 表中的列时,我们可以用占位符的 ID 填充它 用户行。 我们在 up.sql 文件中使用以下表定义来执行此操作:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR NOT NULL UNIQUE,
    email VARCHAR NOT NULL UNIQUE,
    password VARCHAR NOT NULL,
    unique_id VARCHAR NOT NULL
);

这很简单。 请注意,电子邮件和用户名字段是唯一的。 这是因为我们不希望用户拥有重复的用户名和电子邮件。 出于多种原因,将约束置于这个级别是件好事。 例如,我们可以通过对用户名和电子邮件进行数据库调用并在存在重复时拒绝插入新用户来防止这种情况。

但是,代码中可能存在错误,或者将来有人可能会更改我们的代码。 可能会引入没有此检查的新功能,例如编辑功能。 可能存在更改行或插入新用户的迁移。 如果您正在编写自己的 SQL,通常最好的做法是确保使用 ; 符号表示操作已完成。

该 SQL 命令被触发,然后下一个命令被触发。 我们在 up.sql 文件中的下一个命令使用以下命令插入占位符用户行:

 INSERT INTO users (username, email, password, unique_id)
VALUES ('placeholder', 'placeholder email',
'placeholder password', 'placeholder unique id');

现在我们已经创建了用户,然后我们更改 to_do 表。 我们可以使用以下命令来完成此操作,在我们刚刚编写的上一个命令下的同一文件中:

ALTER TABLE to_do ADD user_id integer default 1
CONSTRAINT user_id REFERENCES users NOT NULL;

这样,我们的 up.sql 迁移就已经定义好了。 现在,我们必须定义 down.sql 迁移。 对于向下迁移,我们基本上必须逆转我们在向上迁移中所做的事情。 这意味着删除 to_do 表中的 user_id 列,然后完全删除 user 表。 这可以通过 down.sql 文件中的以下 SQL 代码来完成:

ALTER TABLE to_do DROP COLUMN user_id;
DROP TABLE users

我们必须记住,Docker 必须正在运行,迁移才能影响数据库。 运行此迁移后,我们可以看到以下代码已添加到 src/schema.rs 文件中:

joinable!(to_do -> users (user_id));
allow_tables_to_appear_in_same_query!(
    to_do,
    users,
);

这使得我们的 Rust 数据模型能够查询用户和待办事项之间的关系。 迁移完成后,我们可以再次运行我们的应用程序。 然而,在我们这样做之前,我们只需要在 src/views/to_do/create.rs 文件中进行一点小小的修改,其中创建视图函数中新项目的构造函数添加默认用户 ID 和 以下代码行:

let new_post = NewItem::new(title, 1);

现在运行我们的应用程序将导致我们在第 6 章“使用 PostgreSQL 进行数据持久化”中描述的相同行为,因为我们的应用程序正在运行我们所做的迁移。 但是,我们还需要查看新用户的构造函数在散列密码并生成唯一 ID 时是否有效。

为此,我们需要构建一个创建用户端点。 为此,我们必须定义模式,然后定义一个将该新用户插入数据库的视图。 我们可以使用以下代码在 src/json_serialization/new_user.rs 文件中创建模式:

use serde::Deserialize;
#[derive(Deserialize)]
pub struct NewUserSchema {
    pub name: String,
    pub email: String,
    pub password: String
}

之后,我们可以使用 pub mod new_user; 在 src/json_serialization/mod.rs 文件中声明新的用户模式。 定义模式后,我们可以使用以下文件结构创建自己的用户视图模块:

views
...
└── users
    ├── create.rs
    └── mod.rs

在我们的 users/create.rs 文件中,我们需要构建一个创建视图函数。 首先,导入以下crates:

use crate::diesel;
use diesel::prelude::*;
use actix_web::{web, HttpResponse, Responder};
use actix_web::HttpResponseBuilder;
use crate::database::DB;
use crate::json_serialization::new_user::NewUserSchema;
use crate::models::user::new_user::NewUser;
use crate::schema::users;

由于我们已经多次建立我们的观点,因此这些导入应该不会令人惊讶。 我们导入diesel宏和crate,以便我们能够调用数据库。 然后,我们导入 actix_web 特征和结构以使数据能够流入和流出视图。 然后,我们导入模式和结构来构建我们正在接收和处理的数据。 现在我们已经导入了正确的 crate,我们必须使用以下代码定义创建视图函数:

pub async fn create(new_user: web::Json<NewUserSchema>,
                    db: DB) -> impl Responder {
    . . .
}

在这里,我们可以看到我们接受加载到 NewUserSchema 结构中的 JSON 数据。 我们还从连接池与 DB 结构建立数据库连接。 在创建视图函数中,我们从 NewUserSchema 结构中提取所需的数据,以使用以下代码创建 NewUser 结构:

let new_user = NewUser::new(
    new_user.name.clone(),
    new_user.email.clone(),
    new_user.password.clone()
);

我们必须克隆要传递到 NewUser 构造函数中的字段,因为字符串不实现 Copy 特征,这意味着我们必须手动执行此操作。 然后我们为数据库创建插入命令并使用以下代码执行它:

let insert_result = diesel::insert_into(users::table)
                            .values(&new_user)
                            .execute(&db.connection);

这将返回一个 Result 结构。 但是,我们不会直接拆开它。 可能会发生冲突。 例如,我们可能尝试使用数据库中已有的用户名或电子邮件插入新用户。 然而,我们不希望这只是错误。 这是我们所期望的边缘情况,因为我们自己实现了唯一的用户名和电子邮件限制。 如果在执行视图时发生了合法错误,我们需要了解它。 因此,我们必须为边缘情况提供响应代码。 因此,我们匹配插入的结果并返回相应的响应代码,代码如下:

match insert_result {
    Ok(_) => HttpResponse::Created(),
    Err(_) => HttpResponse::Conflict()
}

在这里,我们建立了一个数据库连接,从 JSON 正文中提取字段,创建一个新的 NewUser 结构,然后将其插入数据库。 与其他观点相比,这里略有不同。 在返回响应中,我们必须等待然后解开它。 这是因为我们没有返回 JSON 正文。 因此,HttpResponse::Ok() 只是一个构建器结构。

现在我们已经构建了创建视图,我们需要在views/users/mod.rs 文件中定义视图工厂,如下所示:

mod create;
use actix_web::web::{ServiceConfig, post, scope};
pub fn user_views_factory(app: &mut ServiceConfig) {
    app.service(
        scope("v1/user")
        .route("create", post().to(create::create))
    );
}

同样,由于我们一直在定期构建视图,所以这一切都不应该让您感到惊讶。 如果是这样,为了清楚起见,建议您阅读第 3 章“处理 HTTP 请求”中的使用 Actix Web 框架管理视图部分。 现在,views/mod.rs 文件中的主视图工厂应如下所示:

mod auth;
mod to_do;
mod app;
mod users;
use auth::auth_views_factory;
use to_do::to_do_views_factory;
use app::app_views_factory;
use users::user_views_factory;
use actix_web::web::ServiceConfig;
pub fn views_factory(app: &mut ServiceConfig) {
    auth_views_factory(app);
    to_do_views_factory(app);
    app_views_factory(app);
    user_views_factory(app);
}

现在我们已经注册了用户视图,我们可以运行我们的应用程序并使用以下 Postman 调用创建用户:


这样,我们应该会得到 201 创建的响应。 如果我们再次调用完全相同的调用,我们应该会收到 409 冲突。 至此,我们应该期望我们的新用户已经创建。 通过第 6 章“使用 PostgreSQL 进行数据持久化”中的“使用 Diesel 连接到 PostgreSQL”部分中介绍的步骤,我们可以检查 Docker 容器中的数据库,这会为我们提供以下打印输出:

 id |    name     |       email
----+-------------+-------------------
  1 | placeholder | placeholder email
  2 | maxwell     | test@gmail.com
                           password
-------------------------------------------------------------
 placeholder password
 $2b$12$jlfLwu4AHjrvTpZrB311Y.W0JulQ71WVy2g771xl50e5nS1UfqwQ.
              unique_id
--------------------------------------
 placeholder unique id
 543b7aa8-e563-43e0-8f62-55211960a604

在这里,我们可以看到在迁移中创建的初始用户。 但是,我们还可以通过视图看到我们创建的用户。 在这里,我们有一个散列密码和一个唯一的 ID。 由此可见,我们永远不应该直接创建我们的用户; 我们应该只通过属于 NewUser 结构的构造函数创建一个用户。

在我们的应用程序上下文中,我们实际上并不需要唯一的 ID。 然而,在使用多个服务器和数据库的更广泛的情况下,唯一的 ID 会变得有用。 我们还必须指出,我们对第二个问题的冲突反应是正确的; 第三个副本创建用户调用,没有将副本用户插入数据库。

这样,我们的应用程序就可以正常运行,因为现在有一个用户表,其中的用户模型链接到待办事项。 这样,我们就可以创建其他具有关系和结构迁移的数据表,以便它们能够无缝升级和降级。 我们还介绍了如何验证和创建密码。 然而,我们实际上并没有编写任何代码来检查用户是否传递了正确的凭据。 在下一部分中,我们将致力于对用户进行身份验证并拒绝不包含正确凭据的请求。

验证我们的用户

在验证用户身份时,我们构建了一个结构体,用于从 HTTP 请求的标头中提取消息。 我们现在正处于可以通过在标头中存储有关用户的数据来真正利用此提取的阶段。 目前,没有什么可以阻止我们将用户名、ID 和密码存储在每个 HTTP 请求的标头中,以便我们可以对每个请求进行身份验证。 然而,这是一种可怕的做法。 如果有人拦截请求或获取浏览器中存储的数据以实现此目的,那么帐户就会受到威胁,黑客就可以为所欲为。 相反,我们将对数据进行混淆,如下图所示:



在图 7.5 中,我们可以看到我们使用密钥将用户的结构化数据序列化为以字节为单位的令牌。 然后,我们将令牌提供给用户以存储在浏览器中。 当用户想要发出授权请求时,用户必须在请求标头中发送令牌。 然后,我们的服务器使用密钥将令牌反序列化回有关用户的结构化数据。 用于执行此过程的算法是任何人都可以使用的标准哈希算法。 因此,我们定义了一个密钥来保证令牌在野外的安全。 为了让我们的应用程序执行图 7.5 中列出的流程,我们将不得不重写大部分 src/jwt.rs 文件,包括 JwToken 结构。 在开始之前,我们需要使用以下代码更新 Cargo.toml 依赖项:

[dependencies]
. . .
chrono = {version = "0.4.19", features = ["serde"]}
. . .
jsonwebtoken = "8.1.0"

我们可以看到我们已经将 serde 功能添加到 chrono crate 中,并添加了 jsonwebtoken crate。 要重建 JwToken 结构,我们需要在 src/jwt.rs 文件中导入以下内容:

use actix_web::dev::Payload;
use actix_web::{Error, FromRequest, HttpRequest};
use actix_web::error::ErrorUnauthorized;
use futures::future::{Ready, ok, err};
use serde::{Deserialize, Serialize};
use jsonwebtoken::{encode, decode, Algorithm, Header,
                   EncodingKey, DecodingKey, Validation};
use chrono::{DateTime, Utc};
use chrono::serde::ts_seconds;
use crate::config::Config;

我们可以看到我们导入了 actix_web 特征和结构来启用请求和响应的处理。 然后我们导入 futures 以使我们能够在 HTTP 请求到达视图之前处理它的拦截。 然后,我们导入 serde 和 jsonwebtoken 以启用令牌数据的序列化和反序列化。 然后我们导入 chrono crate,因为我们想要记录这些代币的铸造时间。 我们还需要有序列化的密钥,我们从配置文件中获取它,这就是我们导入 Config 结构的原因。 现在我们已经导入了所需的所有特征和结构,我们可以使用以下代码编写令牌结构:

#[derive(Debug, Serialize, Deserialize)]
pub struct JwToken {
    pub user_id: i32,
    #[serde(with = "ts_seconds")]
    pub minted: DateTime<Utc>
}

在这里,我们可以看到我们有用户的 ID,还有创建令牌的日期时间。 我们还用 serde 宏装饰我们的 minted 字段,以说明我们将如何序列化日期时间字段。 现在我们已经有了令牌所需的数据,我们可以继续使用以下代码定义序列化函数:

impl JwToken {
    pub fn get_key() -> String {
        . . .
    }
    pub fn encode(self) -> String {
        . . .
    }
    pub fn new(user_id: i32) -> Self {
        . . .
    }
    pub fn from_token(token: String) -> Option<Self> {
        . . .
    }
}

我们可以用以下要点来解释上述每个函数的作用:

get_key:从config.yml文件中获取序列化和反序列化的密钥。

encode:将 JwToken 结构中的数据编码为令牌

new:创建一个新的 JwToken 结构

from_token:从令牌创建 JwToken 结构。 如果反序列化失败,它将返回 None,因为反序列化可能会失败。

一旦我们构建了前面的函数,我们的 JwToken 结构体将能够在我们认为合适的时候处理令牌。 我们使用以下代码充实 get_key 函数:

pub fn get_key() -> String {
    let config = Config::new();
    let key_str = config.map.get("SECRET_KEY")
                            .unwrap().as_str()
                            .unwrap();
    return key_str.to_owned()
}

在这里,我们可以看到我们从配置文件加载密钥。 因此,我们需要将密钥添加到 config.yml 文件中,导致我们的文件如下所示:

DB_URL: postgres://username:password@localhost:5433/to_do
SECRET_KEY: secret

如果我们的服务器正在生产中,我们应该有一个更好的密钥。 然而,对于本地开发来说,这会很有效。 现在我们正在从配置文件中提取密钥,我们可以使用以下代码定义我们的编码函数:

pub fn encode(self) -> String {
    let key = EncodingKey::
              from_secret(JwToken::get_key().as_ref());
    let token = encode(&Header::default(), &self,
                       &key).unwrap();
    return token
}

在这里,我们可以看到我们已经使用配置文件中的密钥定义了一个编码密钥。 然后,我们使用此密钥将 JwToken 结构中的数据编码为令牌并返回它。 现在我们可以对 JwToken 结构进行编码,我们需要在需要时创建新的 JwToken 结构,这可以通过以下new函数来实现:

pub fn new(user_id: i32) -> Self {
    let timestamp = Utc::now();
    return JwToken { user_id, minted: timestamp};
}

通过构造函数,我们知道 JwToken 何时被创建。 如果需要的话,这可以帮助我们管理用户会话。 例如,如果令牌的年龄超过了我们认为适当的阈值,我们可以强制再次登录。

现在,我们拥有的只是 from_token 函数,我们使用以下代码从令牌中提取数据:

pub fn from_token(token: String) -> Option<Self> {
    let key = DecodingKey::from_secret(
                JwToken::get_key().as_ref()
              );
    let token_result = decode::<JwToken>(
                        &token, &key,
                        &Validation::new(Algorithm::HS256)
                        );
    match token_result {
        Ok(data) => {
            Some(data.claims)
        },
        Err(_) => {
            return None
        }
    }
}

在这里,我们定义一个解码密钥,然后用它来解码令牌。 然后我们使用 data.claims 返回 JwToken。 现在,可以创建我们的 JwToken 结构,将其编码为令牌,并从令牌中提取。 现在,我们需要做的就是在加载视图之前从 HTTP 请求的标头中提取它,使用以下大纲:

impl FromRequest for JwToken {
    type Error = Error;
    type Future = Ready<Result<JwToken, Error>>;
    fn from_request(req: &HttpRequest,
                    _: &mut Payload) -> Self::Future {
        . . .
    }
}

我们现在已经为数据库连接多次实现了 FromRequest 特征,并且之前为 JwToken 结构实现了。 在 from_request 函数中,我们使用以下代码从标头中提取令牌:

match req.headers().get("token") {
    Some(data) => {
        . . .
    },
    None => {
        let error = ErrorUnauthorized(
                    "token not in header under key 'token'"
                    );
        return err(error)
    }
}

如果token不在header中,我们直接返回ErrorUnauthorized,完全避免了对视图的调用。 如果我们设法从标头中提取令牌,我们可以使用以下代码对其进行处理:

Some(data) => {
    let raw_token = data.to_str()
                        .unwrap()
                        .to_string();
    let token_result = JwToken::from_token(
                                raw_token
                            );
    match token_result {
        Some(token) => {
            return ok(token)
        },
        None => {
            let error = ErrorUnauthorized(
                            "token can't be decoded"
                        );
            return err(error)
        }
    }
},

在这里,我们将从标头中提取的原始标记转换为字符串。 然后我们反序列化令牌并将其加载到 JwToken 结构中。 但是,如果由于提供了假令牌而失败,我们将返回 ErrorUnauthorized 错误。 我们的身份验证现已完全正常工作; 但是,我们将无法执行任何操作,因为我们没有有效的令牌,如下图所示:



在下一节中,我们将构建登录 API 端点,使我们能够与受保护的端点进行交互。

管理用户会话

对于我们的用户,我们必须让他们能够登录。这意味着我们必须创建一个端点来检查他们的凭据,然后生成一个 JWT,通过响应中的标头在前端返回给用户。 我们的第一步是使用以下代码在 src/json_serialization/login.rs 文件中定义登录模式:

use serde::Deserialize;
#[derive(Deserialize)]
pub struct Login {
    pub username: String,
    pub password: String
}

我们必须记住使用 pub mod 登录在 src/json_serialization/mod.rs 文件中注册它; 行代码。 完成此操作后,我们就可以构建登录端点。 我们可以通过编辑我们在第 3 章“处理 HTTP 请求”中的“使用 Actix Web 框架管理视图”部分创建的 src/views/auth/login.rs 文件来完成此操作,该文件声明了我们的基本登录视图。 这仅返回一个字符串。

现在,我们可以通过定义所需的导入来开始重构此视图,如以下代码所示:

use crate::diesel;
use diesel::prelude::*;
use actix_web::{web, HttpResponse, Responder};
use crate::database::DB;
use crate::models::user::user::User;
use crate::json_serialization::login::Login;
use crate::schema::users;
use crate::jwt::JwToken;

在这个阶段,我们可以浏览一下导入并了解我们要做什么。 我们将从正文中提取用户名和密码。 然后,我们将连接到数据库以检查用户和密码,然后使用 JwToken 结构创建将传递回用户的令牌。 我们可以首先在同一文件中使用以下代码来布置视图的轮廓:

. . .
Use std::collections::HashMap;
pub async fn login(credentials: web::Json<Login>,
                   db: DB) -> impl HttpResponse {
    . . .
}

在这里,我们可以看到我们接受传入请求正文中的登录凭据,并从连接池中为视图准备数据库连接。 然后,我们可以从请求正文中提取所需的详细信息,并使用以下代码进行数据库调用:

let password = credentials.password.clone();
let users = users::table
    .filter(users::columns::username.eq(
        credentials.username.clone())
    ).load::<User>(&db.connection).unwrap();

现在,我们必须使用以下代码检查数据库调用是否得到了我们期望的结果:

if users.len() == 0 {
    return HttpResponse::NotFound().await.unwrap()
} else if users.len() > 1 {
    return HttpResponse::Conflict().await.unwrap()
}

在这里,我们做了一些提前返回。 如果没有用户,则我们返回未找到响应代码。 这是我们不时期待的事情。 但是,如果有多个用户使用该用户名,我们需要返回不同的代码。

由于所显示的独特限制,有些事情是非常错误的。 将来的迁移脚本可能会撤消这些独特的约束,或者用户查询可能会被意外更改。 如果发生这种情况,我们需要立即知道这种情况已经发生,因为违反我们的约束的损坏数据可能会导致我们的应用程序以意想不到的方式运行,从而难以排除故障。

现在我们已经检查了是否已检索到正确数量的用户,我们可以放心地获取索引为零的唯一用户,并检查他们的密码是否可以通过,如下所示:

match users[0].verify(password) {
    true => {
        let token = JwToken::new(users[0].id);
        let raw_token = token.encode();
        let mut body = HashMap::new();
      body.insert("token", raw_token);
        HttpResponse::Ok().json(body)
    },
    false => HttpResponse::Unauthorized()
}

在这里,我们可以看到我们使用了验证函数。 如果密码匹配,我们就会使用 ID 生成一个令牌并将其返回给正文中的用户。 如果密码不正确,我们将返回未经授权的代码。

就注销而言,我们将采取更轻量级的方法。 我们在注销视图中必须做的就是运行两行 JavaScript 代码。 一种是从本地存储中删除用户令牌,然后将用户恢复到主视图。 HTML 可以只托管 JavaScript,一旦您打开它,它就会运行。 因此,我们可以通过在 src/views/auth/logout.rs 文件中添加以下代码来实现这一点:

use actix_web::HttpResponse;
pub async fn logout() -> HttpResponse {
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body("<html>\
                <script>\
                    localStorage.removeItem('user-token'); \
                    window.location.replace(
                        document.location.origin);\
                </script>\
              </html>")
}

由于该视图已经注册,我们可以运行应用程序并使用 Postman 进行调用:



更改用户名将为我们提供 404 响应代码,而更改密码将为我们提供 401 响应代码。 如果我们有正确的用户名和密码,我们将得到一个200响应代码,并且响应头中会有一个令牌,如图7.7所示。 但是,如果我们想在响应标头中使用令牌,我们将收到令牌无法解码的消息。 在下一节中,我们将清理我们的身份验证要求。

清理身份验证要求

在本节中,我们将在开始配置前端来处理这些身份验证过程之前,在身份验证方面清理 Rust 服务器。 为了保持本章的流畅性,我们没有定期进行“内务整理”。 现在,我们将更新我们的 to_do 视图。 我们可以首先使用身份验证要求更新创建视图。 为此,src/views/to_do/create.rs 文件中创建视图的函数签名应如下所示:

. . .
use crate::jwt::JwToken;
use crate::database::DB
pub async fn create(token: JwToken,
                    req: HttpRequest, db: DB) -> HttpResponse {
    . . .

在使用令牌中的 ID 创建新项目时,我们还必须使用以下代码更新用户 ID:

if items.len() == 0 {
    let new_post = NewItem::new(title, token.user_id);
    let _ = diesel::
            insert_into(to_do::table).values(&new_post)
        .execute(&db.connection);
}
Return HttpResponse::Ok().json(
    ToDoItems::get_state(token.user_id)
)

使用删除视图,我们必须确保删除属于发出请求的用户的待办事项。 如果我们不使用用户 ID 添加过滤器,则待办事项的删除将是随机的。 可以使用以下代码将此过滤器添加到我们的 src/views/to_do/delete.rs 文件中:

. . .
Use crate::database::DB;
. . .
pub async fn delete(to_do_item: web::Json<ToDoItem>,
                    token: JwToken, db: DB) -> HttpResponse {
    let items = to_do::table
        .filter(to_do::columns::title.eq(
                    &to_do_item.title.as_str())
                )
        .filter(to_do::columns::user_id.eq(&token.user_id))
        .order(to_do::columns::id.asc())
        .load::<Item>(&db.connection)
        .unwrap();
    let _ = diesel::delete(&items[0]).execute(&db.connection);
    return HttpResponse::Ok().json(ToDoItems::get_state(
        token.user_id
    ))
}

我们可以看到,在进行数据库查询时,过滤器函数只能被链接起来。 考虑到我们对删除视图所做的操作,您认为我们将如何升级 src/views/to_do/edit.rs 文件中编辑的身份验证要求? 在这个阶段,我鼓励您尝试自己更新编辑视图,因为该方法类似于我们的删除视图升级。 完成此操作后,您的编辑视图应类似于以下代码:

pub async fn edit(to_do_item: web::Json<ToDoItem>,
                  token: JwToken, db: DB) -> HttpResponse {
    let results = to_do::table.filter(to_do::columns::title
                              .eq(&to_do_item.title))
                              .filter(to_do::columns::user_
                                      id
                              .eq(&token.user_id));
    let _ = diesel::update(results)
        .set(to_do::columns::status.eq("DONE"))
        .execute(&db.connection);
    return HttpResponse::Ok().json(ToDoItems::get_state(
                                   token.user_id
    ))
}

现在我们已经更新了特定的视图,现在可以转到 get 视图,该视图还具有应用于所有其他视图的 get_state 函数。 src/views/to_do/get.rs 文件中的 get 视图现在采用以下形式:

use actix_web::Responder;
use crate::json_serialization::to_do_items::ToDoItems;
use crate::jwt::JwToken;
pub async fn get(token: JwToken) -> impl Responder {
    ToDoItems::get_state(token.user_id)
}

现在,前面代码中的所有内容都不足为奇了。 我们可以看到我们将用户 ID 传递到了 ToDoItems::get_state 函数中。 您必须记住在实现 ToDoItems::get_state 函数的所有地方填写用户 ID,即所有待办事项视图。 然后,我们可以使用以下代码在 src/json_serialization/to_do_items.rs 文件中重新定义 ToDoItems::get_state 函数:

. . .
use crate::database::DBCONNECTION;
. . .
impl ToDoItems {
    . . .
    pub fn get_state(user_id: i32) -> ToDoItems {
        let connection = DBCONNECTION.db_connection.get()
                         .unwrap();
        let items = to_do::table
                    .filter(to_do::columns::user_id.eq
                           (&user_id))
                    .order(to_do::columns::id.asc())
                    .load::<Item>(&connection)
                    .unwrap();
        let mut array_buffer = Vec::
                               with_capacity(items.len());
        for item in items {
            let status = TaskStatus::from_string(
            &item.status.as_str().to_string());
            let item = to_do_factory(&item.title, status);
            array_buffer.push(item);
        }
        return ToDoItems::new(array_buffer)
    }
}

在这里,我们可以看到我们已经更新了数据库连接和用户 ID 的过滤器。 我们现在更新了代码以适应不同的用户。 我们还必须做出一项改变。 因为我们将在 React 应用程序中编写前端代码,所以我们将尝试使 React 编码尽可能简单,因为 React 开发本身就是一本书。 为了避免使用 Axios 进行标头提取和 GET 帖子的前端开发过于复杂,我们将在登录中添加一个 Post 方法,并使用正文返回令牌。 这是另一个尝试自己解决这个问题的好机会,因为我们已经涵盖了实现这个目标所需的所有概念。

如果您尝试自己解决这个问题,它应该如下所示。 首先,我们使用以下代码在 src/json_serialization/login_response.rs 文件中定义一个响应结构:

use serde::Serialize;
#[derive(Serialize)]
pub struct LoginResponse {
    pub token: String
}

我们记得通过在 src/json_serialization/mod.rs 文件中放入 pub mod login_response 来声明前面的结构。 现在,我们转到 src/views/auth/login.rs 并在登录函数中有以下 return 语句:

match users[0].clone().verify(credentials.password.clone()) {
    true => {
        let user_id = users[0].clone().id;
        let token = JwToken::new(user_id);
        let raw_token = token.encode();
        let response = LoginResponse{token:
                                     raw_token.clone()};
        let body = serde_json::
                   to_string(&response).unwrap();
        HttpResponse::Ok().append_header(("token",
                           raw_token)).json(&body)
    },
    false => HttpResponse::Unauthorized().finish()
}

笔记

您可能已经注意到,我们对未经授权的以下内容做了轻微更改:

HttpResponse::Unauthorized().finish()

这是因为我们已将视图函数的返回类型切换为 HttpResponse 结构,为我们提供以下函数签名:

(credentials: web::Json<Login>, db: DB) -> HttpResponse

我们必须进行切换,因为将 json 函数添加到我们的响应中会将我们的响应从 HttpResponseBuilder 转变为 HttpResponse。 一旦调用了 json 函数,就无法使用 HttpResponseBuilder。 回到未授权的响应构建器,我们可以推断完成函数将 HttpResponseBuilder 转换为 HttpResponse。 我们还可以使用await将我们的HttpResponseBuilder转换为HttpResponse,如下代码所示:

HttpResponse::Unauthorized().await.unwrap()

在这里,我们可以看到我们在标头和正文中返回了令牌。 这将为我们编写前端代码提供灵活性和轻松性。 然而,必须强调的是,这不是最佳实践。 我们正在实现将令牌传递回正文和标头的方法,以保持前端开发部分的简单。 然后,我们可以使用以下代码在 src/views/auth/mod.rs 文件中为登录视图启用 POST 方法:

mod login;
mod logout;
use actix_web::web::{ServiceConfig, get, post, scope};
pub fn auth_views_factory(app: &mut ServiceConfig) {
    app.service(
            scope("v1/auth")
            .route("login", get().to(login::login))
            .route("login", post().to(login::login))
            .route("logout", get().to(logout::logout))
    );
}

我们可以看到我们只是将 get 函数堆叠到同一个登录视图上。 现在,POST 和 GET 可用于我们的登录视图。 现在,我们可以进入下一部分,配置身份验证令牌,以便它们可以过期。 我们希望我们的代币过期以提高我们的安全性。 如果令牌被泄露并且坏人获得了令牌,他们将能够做任何他们想做的事情,只要他们想做,而无需登录。但是,如果我们的令牌过期,那么坏人就只能 令牌过期之前的有限时间窗口。

配置身份验证令牌的过期时间

如果我们尝试使用通过标头中的令牌登录而获得的有效令牌在现在受保护的端点上执行 API 调用,我们将收到未经授权的错误。 如果我们插入一些打印语句,当无法解码令牌时,我们将收到以下错误:

missing required claim: exp

这意味着我们的 JwToken 结构中没有名为 exp 的字段。 如果我们引用 https://docs.rs/jsonwebtoken/latest/jsonwebtoken/fn.encode.html 上的 jsonwebtoken 文档,我们可以看到编码指令从未提及 exp:

use serde::{Deserialize, Serialize};
use jsonwebtoken::{encode, Algorithm, Header, EncodingKey};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
   sub: String,
   company: String
}
let my_claims = Claims {
    sub: "b@b.com".to_owned(),
    company: "ACME".to_owned()
};
// my_claims is a struct that implements Serialize
// This will create a JWT using HS256 as algorithm
let token = encode(&Header::default(), &my_claims,
&EncodingKey::from_secret("secret".as_ref())).unwrap();

在这里,我们可以看到没有提及索赔。 然而,发生的情况是,当我们尝试反序列化令牌时,jsonwebtoken 箱中的解码函数会自动查找 exp 字段,以计算出令牌何时过期。 我们正在探索这个问题,因为官方文档和稍微令人困惑的错误消息可能会让您浪费时间试图弄清楚发生了什么。 考虑到这一点,我们必须返回到 src/jwt.rs 文件进行更多重写,但我保证,这是最后一次,而且这不是整个重写。 首先,我们确保以下内容与 src/jwt.rs 文件中已有的内容一起导入:

. . .
use jsonwebtoken::{encode, decode, Header,
                   EncodingKey, DecodingKey,
                   Validation};
use chrono::Utc;
. . .

然后,我们可以确保您的 JwT 令牌结构是使用 exp 字段编写的,代码如下:

#[derive(Debug, Serialize, Deserialize)]
pub struct JwToken {
    pub user_id: i32,
    pub exp: usize,
}

我们现在必须为 JwToken 结构重写新的构造函数方法。 在新函数中,我们必须定义新创建的 JwToken 结构何时过期。 这必须有所不同; 作为开发人员,您可能想要调整超时时间。 请记住,每次更改 Rust 代码时都必须重新编译; 因此,在配置文件中定义超时时间是有意义的。 考虑到超时的变化,我们的新函数采用以下形式:

pub fn new(user_id: i32) -> Self {
    let config = Config::new();
    let minutes = config.map.get("EXPIRE_MINUTES")
                            .unwrap().as_i64().unwrap();
    let expiration = Utc::now()
    .checked_add_signed(chrono::Duration::minutes(minutes))
                            .expect("valid timestamp")
                            .timestamp();
    return JwToken { user_id, exp: expiration as usize };
}

我们可以看到我们定义了分钟数。 然后,我们将过期时间转换为 usize,然后构建 JwToken 结构。 现在我们已经有了这个,我们需要更具体地了解返回的错误类型,因为它可能是令牌解码中的错误,或者令牌可能已过期。 我们使用以下代码在解码令牌时处理不同类型的错误:

pub fn from_token(token: String) -> Result<Self, String> {
    let key = DecodingKey::
              from_secret(JwToken::get_key().as_ref());
    let token_result = decode::<JwToken>(&token.as_str(),
                              &key,&Validation::default());
    match token_result {
        Ok(data) => {
            return Ok(data.claims)
        },
        Err(error) => {
            let message = format!("{}", error);
            return Err(message)
        }
    }
}

在这里,我们可以看到我们已经从返回 Option 切换为 Result。 我们已切换到 Result,因为我们返回的消息可以在 FromRequest 特征实现中的 from_request 函数中消化和处理。 from_request 函数中的其余代码是相同的。 我们进行更改的地方是检查消息是否有错误,并使用以下代码向前端返回不同的消息:

fn from_request(req: &HttpRequest,
                _: &mut Payload) -> Self::Future {
    match req.headers().get("token") {
        Some(data) => {
            let raw_token = data.to_str()
                                .unwrap()
                                .to_string();
            let token_result = JwToken::
                        from_token(raw_token);
            match token_result {
                Ok(token) => {
                    return ok(token)
                },
                Err(message) => {
                    if message == "ExpiredSignature"
                                  .to_owned() {
                        return err(
                        ErrorUnauthorized("token expired"))
                    }
                    return err(
                    ErrorUnauthorized("token can't be decoded"))
                }
            }
        },
        None => {
            return err(
            ErrorUnauthorized(
            "token not in header under key 'token'"))
        }
    }
}

有了细致入微的错误消息,我们的前端代码就可以处理和适应,因为我们可以更具体地了解如何处理前端中的错误。 在前端提供更具体的信息可以帮助用户,提示他们哪里出错了。 但是,在身份验证方面,请确保不要泄露太多信息,因为这也会帮助不良行为者试图获得未经授权的访问。 现在我们的登录和注销端点正在运行; 我们还对我们需要的视图进行了令牌授权。 但是,如果我们希望标准用户与我们的应用程序交互,这并不是很有用,因为他们不太可能使用 Postman。 因此,我们必须在下一节中将登录/注销端点合并到前端中。

将身份验证添加到前端

我们整合了登录功能。 我们必须首先在 src/components/LoginForm.js 文件中构建登录表单。 首先,我们导入以下内容:

import React, {Component} from 'react';
import axios from 'axios';
import '../css/LoginForm.css';

本章附录中提供了导入 CSS 的代码。 我们不会在这里详细介绍它,因为它有很多重复的代码。 您还可以从 GitHub 存储库下载 CSS 代码。 通过这些导入,我们可以使用以下代码构建登录表单的框架:

class LoginForm extends Component {
    state = {
        username: "",
        password: "",
    }
    submitLogin = (e) => {
        . . .
    }
    handlePasswordChange = (e) => {
        this.setState({password: e.target.value})
    }
    handleUsernameChange = (e) => {
        this.setState({username: e.target.value})
    }
    render() {
        . . .
    }
}
export default LoginForm;

在这里,我们可以看到我们跟踪不断更新状态的用户名和密码。 请记住,当状态更新时,我们执行渲染函数。 这很强大,因为我们可以改变我们想要的任何东西。 例如,如果用户名的长度超过一定长度,我们可以更改组件的颜色或删除按钮。 我们自己不会做出重大改变,因为这超出了本书的范围。 现在我们已经定义了框架,我们可以使用以下代码声明渲染函数返回的内容:

<form className="login" onSubmit={this.submitLogin}>
    <h1 className="login-title">Login</h1>
    <input type="text" className="login-input"
    placeholder="Username"
    autoFocus onChange={this.handleUsernameChange}
           value={this.state.username} />
    <input type="password" className="login-input"
    placeholder="Password"
    onChange={this.handlePasswordChange}
           value={this.state.password} />
    <input type="submit" value="Lets Go"
    className="login-button" />
</form>

在这里,我们可以看到表单中有用户名和密码字段,当发生更改时,它们会执行handleUsernameChange和handlePasswordChange函数。 当我们输入用户名和密码时,我们需要通过submitLogin函数将这些字段提交到后端,我们可以在这里定义:

submitLogin = (e) => {
    e.preventDefault();
    axios.post("http://localhost:8000/v1/auth/login",
        {"username": this.state.username,
         "password": this.state.password},
        {headers: {"Access-Control-Allow-Origin": "*"}}
        )
        .then(response => {
            this.setState({username: "", password: ""});
            this.props.handleLogin(response.data["token"]);
        })
        .catch(error => {
            alert(error);
            this.setState({password: "", firstName: ""});
        });
}

在这里,我们可以看到我们将登录 API 调用的响应传递给使用 props 传递的函数。 我们必须在 src/app.js 文件中定义它。 如果出现错误,我们会在警报中打印出来,告诉我们发生了什么。 无论哪种方式,我们都会清空用户名和密码字段。

现在我们已经定义了登录表单,当我们需要用户登录时,我们需要显示它。用户登录后,我们需要隐藏登录表单。 在执行此操作之前,我们需要使用以下代码将登录表单导入到 src/app.js 文件中:

import LoginForm from "./components/LoginForm";

我们现在需要跟踪登录状态。 为此,我们的 App 类的状态需要采用以下形式:

state = {
  "pending_items": [],
  "done_items": [],
  "pending_items_count": 0,
  "done_items_count": 0,
  "login_status": false,
}

我们正在跟踪我们的项目,但如果 login_status 为 false,我们可以显示登录表单。 用户登录后,我们可以将login_status设置为true,这样我们就可以隐藏登录表单。 现在我们正在记录登录状态,我们可以更新 App 类的 getItems 函数:

getItems() {
  axios.get("http://127.0.0.1:8000/v1/item/get",
  {headers: {"token": localStorage.getItem("user-token")}})
  .then(response => {
      let pending_items = response.data["pending_items"]
      let done_items = response.data["done_items"]
      this.setState({
        "pending_items":
         this.processItemValues(pending_items),
        "done_items": this.processItemValues(done_items),
        "pending_items_count":
         response.data["pending_item_count"],
        "done_items_count":
         response.data["done_item_count"]
        })
  }).catch(error => {
      if (error.response.status === 401) {
        this.logout();
      }
  });
}

我们可以看到我们获取到了token并将其放入了header中。 如果未经授权的代码出现错误,我们将执行App类的注销函数。 我们的注销函数采用此处定义的形式:

logout() {
  localStorage.removeItem("token");
  this.setState({"login_status": false});
}

我们可以看到我们从本地存储中删除了令牌并将login_status 设置为false。 如果在尝试编辑待办事项时出现错误,也需要执行此注销功能,因为我们必须记住,我们的令牌可能会过期,因此它可能在任何地方发生,并且我们必须提示再次登录。 这意味着我们必须使用以下代码将注销功能传递到 ToDoItem 组件中:

processItemValues(items) {
  let itemList = [];
  items.forEach((item, _)=>{
      itemList.push(
          <ToDoItem key={item.title + item.status}
                    title={item.title}
                    status={item.status}
                    passBackResponse={this.handleReturnedState}
                    logout={this.logout}/>
      )
  })
  return itemList
}

将注销功能传递到 ToDoItem 组件后,我们可以使用以下代码更新 API 调用以编辑 src/components/ToDoItem.js 文件中的待办事项:

sendRequest = () => {
    axios.post("http://127.0.0.1:8000/v1/item/" +
                this.state.button,
        {
            "title": this.state.title,
            "status": this.inverseStatus(this.state.status)
        },
    {headers: {"token": localStorage.getItem(
         "user-token")}})
        .then(response => {
            this.props.passBackResponse(response);
        }).catch(error => {
            if (error.response.status === 401) {
                this.props.logout();
            }
    });
}

在这里,我们可以看到我们通过标头将令牌从本地存储传递到 API 调用。 如果我们得到未经授权的状态,我们就会执行通过 props 传入的注销函数。

现在我们回到 src/app.js 文件来总结我们应用程序的功能。 请记住,我们的应用程序需要在首次访问数据时加载数据。 当我们的应用程序最初加载时,我们必须使用以下代码考虑本地存储中的令牌:

componentDidMount() {
  let token = localStorage.getItem("user-token");
  if (token !== null) {
      this.setState({login_status: true});
      this.getItems();
  }
}

现在,我们的应用程序只有在有令牌时才会从后端获取项目。 我们必须只在使用渲染函数包装应用程序之前处理登录。 您已经了解了我们如何使用本地存储处理令牌。 此时,您应该能够自己为 App 类构建handleLogin 函数。 如果您尝试编写自己的函数,它应该类似于以下代码:

handleLogin = (token) => {
  localStorage.setItem("user-token", token);
  this.setState({"login_status": true});
  this.getItems();
}

我们现在正处于为 App 类定义渲染函数的阶段。 如果我们的登录状态为 true,我们可以使用以下代码显示我们的应用程序必须提供的所有内容:

if (this.state.login_status === true) {
    return (
        <div className="App">
            <div className="mainContainer">
                <div className="header">
                    <p>complete tasks:
                       {this.state.done_items_count}</p>
                    <p>pending tasks:
                       {this.state.pending_items_count}</p>
                </div>
                <h1>Pending Items</h1>
                {this.state.pending_items}
                <h1>Done Items</h1>
                {this.state.done_items}
                <CreateToDoItem
                 passBackResponse={this.handleReturnedState}/>
            </div>
        </div>
    )
}

这里没有太多新东西。 但是,如果我们的登录状态不正确,我们可以使用以下代码显示登录表单:

else {
    return (
        <div className="App">
            <div className="mainContainer">
                <LoginForm handleLogin={this.handleLogin}
                />
            </div>
        </div>
    )
}

正如我们所看到的,我们已将handleLogin 函数传递到LoginForm 组件中。 这样,我们就可以运行该应用程序了。 我们的第一个视图如下所示:



输入正确的凭据后,我们将能够访问应用程序并与待办事项进行交互。 我们的应用程序基本上可以工作了!

概括

在本章中,我们构建了用户数据模型结构,并将它们与迁移中的待办事项数据模型联系起来。 然后,我们通过触发 SQL 文件中的多个步骤来更深入地研究迁移,以确保迁移顺利进行。 我们还探讨了如何向某些字段添加独特的约束。

在数据库中定义数据模型后,我们会对一些密码进行哈希处理,然后将它们与存储的用户一起存储在数据库中。 然后,我们创建了一个 JWT 结构,使用户能够将其 JWT 存储在浏览器中,以便他们在进行 API 调用时可以提交它们。 然后,我们探讨了如何在 JavaScript 和 HTML 存储中重定向 URL,以便前端可以在考虑向项目发送 API 调用之前确定用户是否拥有凭据。

我们在这里所做的是通过迁移来改变数据库,以便我们的应用程序可以管理处理更复杂的数据模型。 然后,我们利用前端存储来使我们的用户能够传递凭据。 这直接适用于您将开始的任何其他 Rust Web 项目。 大多数网络应用程序都需要某种身份验证。

在下一章中,我们将探索 REST API 实践,其中我们将标准化接口、缓存和日志记录。

问题

与服务器端代码相比,在 SQL 中定义唯一约束有哪些优点?

与存储密码相比,用户拥有 JWT 的主要优势是什么?

用户如何在前端存储 JWT?

一旦我们验证了 JWT 是可以通过的,它在视图中怎么会有用呢?

当用户点击端点时,更改前端数据并将其重定向到另一个视图的最小方法是什么?

为什么以用户身份登录时拥有一系列不同的响应代码很有用,而不是仅仅表示登录成功或不成功?

答案

直接在数据库上添加唯一约束可确保执行此标准,无论数据操作是通过迁移还是服务器请求完成。 如果在另一个忘记执行此标准的端点添加新功能,或者在端点的后续更改中更改了代码,这也可以保护我们免于损坏数据。

如果攻击者设法获取 JWT,并不意味着他们可以直接访问用户的密码。 此外,如果令牌被刷新,那么攻击者对项目的访问权限就会受到限制。

JWT 可以存储在本地 HTML 存储或 cookie 中。

在对令牌进行哈希处理时,我们可以在令牌中存储多个数据点。 因此,我们可以对用户ID进行加密。 这样,我们就可以提取用户 ID 来执行与待办事项的创建、删除或编辑相关的操作。

我们返回一个带有 HTML/文本主体的 HttpResponse 结构,其中包含一个包含几个 HTML 标签的字符串。 在这些标签之间是几个脚本标签。 在脚本标签之间,我们可以将每个 JavaScript 命令用 ; 分隔。 然后我们可以直接更改 HTML 存储和窗口位置。

数据库上的数据损坏可能有多种原因,包括迁移中的更改。 但是,可能会出现并非用户错误的错误,例如,两个不同用户的用户名重复。 这是违反了我们的独特约束的错误。 我们需要知道这种情况已经发生,以便我们能够纠正它。

最近发表
标签列表