Rust 使用 Actix-Web 验证 Auth Web 微服务 - 完整教程第 2 部分

2018-11-15 22:27:10 +08:00
 krircc

本文同步于 Rust 中文社区

验证用户电子邮件

使用来自服务器的 http 响应来验证电子邮件。创建电子邮件验证的最简单方法是让我们的服务器使用通过电子邮件发送到用户电子邮件的某种秘密,并让他们单击带有秘密的链接进行验证。我们可以使用UUID邀请对象作为秘密。假设客户在使用 uuid 输入电子邮件后收到邀请67a68837-a059-43e6-a0b8-6e57e6260f0d

发送请求UUID在网址中注册具有上述内容的新用户。我们的服务器可以获取该 id 并在数据库中找到 Invitation 对象,然后将到期日期与当前时间进行比较。如果所有这些条件都成立,我们将让用户注册,否则返回错误响应。现在我们将邀请对象作为解决方法返回给客户端。电子邮件支持将在第 3 部分中实现。

发送请求 UUID 在网址中注册具有上述内容的新用户。服务器可以获取该 id 并在数据库中找到 Invitation 对象,将到期日期与当前时间进行比较。如果所有条件成立,将让用户注册,否则返回错误,将邀请对象作为解决方法返回给客户端。电子邮件支持将在第 3 部分中实现。

错误处理和FROMTrait

Rust 提供了非常强大的工具,我们可以使用它们将一种错误转换为另一种错误。在这个应用程序中,我们将使用不同的插入操作进行一些操作,即使用柴油保存数据,使用 bcrypt 保存密码等。这些操作可能会返回错误,但我们需要将它们转换为我们的自定义错误类型。std::convert::From是一个 Trait,允许我们转换它。在这里阅读更多有关From特征的信息。通过实现From特征,我们可以使用?运算符来传播将转换为我们的ServiceError类型的许多不同类型的错误。

我们的错误定义在errors.rs,让我们通过为uuiddiesel错误添加 impl From来实现一些From特性,我们还将为ServiceError枚举添加一个Unauthorized变量。该文件如下所示:

// errors.rs
use actix_web::{error::ResponseError, HttpResponse};
use std::convert::From;
use diesel::result::{DatabaseErrorKind, Error};
use uuid::ParseError;


#[derive(Fail, Debug)]
pub enum ServiceError {
    #[fail(display = "Internal Server Error")]
    InternalServerError,

    #[fail(display = "BadRequest: {}", _0)]
 BadRequest(String),

    #[fail(display = "Unauthorized")]
    Unauthorized,
}

// impl ResponseError trait allows to convert our errors into http responses with appropriate data
impl ResponseError for ServiceError {
 fn error_response(&self) -> HttpResponse {
 match *self {
            ServiceError::InternalServerError => HttpResponse::InternalServerError().json("Internal Server Error, Please try later"),
            ServiceError::BadRequest(ref message) => HttpResponse::BadRequest().json(message),
            ServiceError::Unauthorized => HttpResponse::Unauthorized().json("Unauthorized")
        }
    }
}

// we can return early in our handlers if UUID provided by the user is not valid
// and provide a custom message
impl From<ParseError> for ServiceError {
 fn from(_: ParseError) -> ServiceError {
        ServiceError::BadRequest("Invalid UUID".into())
    }
}

impl From<Error> for ServiceError {
 fn from(error: Error) -> ServiceError {
 // Right now we just care about UniqueViolation from diesel
 // But this would be helpful to easily map errors as our app grows
 match error {
            Error::DatabaseError(kind, info) => {
 if let DatabaseErrorKind::UniqueViolation = kind {
 let message = info.details().unwrap_or_else(|| info.message()).to_string();
 return ServiceError::BadRequest(message);
                }
                ServiceError::InternalServerError
            }
            _ => ServiceError::InternalServerError
        }
    }
}

这一切都将让我们做事变得方便。

密码处理

这里建议argon2。为简单起见,使用bcrypt。bcrypt 算法在生产中被广泛使用,并且 y 有好的接口来散列和验证密码。

src/utils.rs定义一个哈希函数

//utils.rs
use bcrypt::{hash, DEFAULT_COST};
use errors::ServiceError;
use std::env;

pub fn hash_password(plain: &str) -> Result<String, ServiceError> {
 // get the hashing cost from the env variable or use default
 let hashing_cost: u32 = match env::var("HASH_ROUNDS") {
 Ok(cost) => cost.parse().unwrap_or(DEFAULT_COST),
        _ => DEFAULT_COST,
    };
 hash(plain, hashing_cost).map_err(|_| ServiceError::InternalServerError)
}

返回一个 Result 并使用map_error ()来返回我们的自定义错误。这是为了允许稍后在我们调用此函数时使用?运算符(另一种转换错误的方法是为Frombcrypt函数返回的错误实现特征)。

为上一个教程models.rs中定义的User结构添加一个方便的方法。我们还删除了remove_pwd ()方法,而是定义了另一个 SlimUser 没有密码字段的结构。我们实现Fromtrait 来从User生成 SlimUser。

use chrono::{NaiveDateTime, Local};
use std::convert::From;
//... snip
impl User {
 pub fn with_details(email: String, password: String) -> Self {
        User {
            email,
            password,
            created_at: Local::now().naive_local(),
        }
    }
}
//--snip
#[derive(Debug, Serialize, Deserialize)]
pub struct SlimUser {
 pub email: String,
}

impl From<User> for SlimUser {
 fn from(user: User) -> Self {
        SlimUser {
           email: user.email
        }
    }
}

不要忘记添加extern crate bcrypt;并mod utils在您的 main.rs 。我在第一部分忘记了另一个是登录到控制台。要启用它,以下内容添加到 main.rs

extern crate env_logger;
// --snip

fn main(){
 dotenv().ok();
    std::env::set_var("RUST_LOG", "simple-auth-server=debug,actix_web=info");
    env_logger::init();
 //--snip
}

注册用户

上一个教程,我们为Invitation创建了一个handler程序,现在创建一个注册用户的处理程序。创建一个 RegisterUser 包含一些数据的结构,允许我们验证邀请,然后从数据库创建并返回一个用户。

src/register_handler.rs并添加mod register_handler到您的文件中 main.rs

// register_handler.rs
use actix::{Handler, Message};
use chrono::Local;
use diesel::prelude::*;
use errors::ServiceError;
use models::{DbExecutor, Invitation, User, SlimUser};
use uuid::Uuid;
use utils::hash_password;

// UserData is used to extract data from a post request by the client
#[derive(Debug, Deserialize)]
pub struct UserData {
 pub password: String,
}

// to be used to send data via the Actix actor system
#[derive(Debug)]
pub struct RegisterUser {
 pub invitation_id: String,
 pub password: String,
}


impl Message for RegisterUser {
 type Result = Result<SlimUser, ServiceError>;
}


impl Handler<RegisterUser> for DbExecutor {
 type Result = Result<SlimUser, ServiceError>;
 fn handle(&mut self, msg: RegisterUser, _: &mut Self::Context) -> Self::Result {
 use schema::invitations::dsl::{invitations, id};
 use schema::users::dsl::users;
 let conn: &PgConnection = &self.0.get().unwrap();

 // try parsing the string provided by the user as url parameter
 // return early with error that will be converted to ServiceError
 let invitation_id = Uuid::parse_str(&msg.invitation_id)?;

        invitations.filter(id.eq(invitation_id))
            .load::<Invitation>(conn)
            .map_err(|_db_error| ServiceError::BadRequest("Invalid Invitation".into()))
            .and_then(|mut result| {
 if let Some(invitation) = result.pop() {
 // if invitation is not expired
 if invitation.expires_at > Local::now().naive_local() {
 // try hashing the password, else return the error that will be converted to ServiceError
 let password: String = hash_password(&msg.password)?;
 let user = User::with_details(invitation.email, password);
 let inserted_user: User = diesel::insert_into(users)
                            .values(&user)
                            .get_result(conn)?;

 return Ok(inserted_user.into()); // convert User to SlimUser
                    }
                }
 Err(ServiceError::BadRequest("Invalid Invitation".into()))
            })
    }
}

解析 URL 参数

actix-web 有许多简单的方法可以从请求中提取数据。其中一种方法是使用 Path 提取器。

Path提供可从 Request 的路径中提取的信息。您可以从路径反序列化任何变量段。

这将允许我们为每个要注册为用户的邀请创建唯一路径。

修改app.rs文件中的寄存器路由,并添加一个稍后我们将实现的处理函数。

// app.rs
/// creates and returns the app after mounting all routes/resources
// add use statement at the top.
use register_routes::register_user;
//...snip
pub fn create_app(db: Addr<DbExecutor>) -> App<AppState> {
    App::with_state(AppState { db })
 //... snip
 // routes to register as a user
        .resource("/register/{invitation_id}", |r| {
           r.method(Method::POST).with(register_user);
        })

}

您可能希望暂时注释更改,因为事情未实现并保持您的应用程序已编译并运行。(我尽可能地做,持续反馈)。

我们现在需要的是实现register_user ()函数,该函数从客户端发送的请求中提取数据,通过向RegisterUserActor 发送消息来调用处理程序。除了 url 参数,我们还需要从客户端提取密码。我们已经为此创建了一个UserData结构体在register_handler.rs。我们将使用类型Json来创建UserData结构。

Json 允许将请求主体反序列化为结构体。要从请求的主体中提取类型信息,类型 T 必须实现 serde 的反序列化特征。

创建一个新文件src/register_routes.rs并添加mod register_routes到您的文件中 main.rs

use actix_web::{AsyncResponder, FutureResponse, HttpResponse, ResponseError, State, Json, Path};
use futures::future::Future;

use app::AppState;
use register_handler::{RegisterUser, UserData};


pub fn register_user((invitation_id, user_data, state): (Path<String>, Json<UserData>, State<AppState>))
                     -> FutureResponse<HttpResponse> {
 let msg = RegisterUser {
 // into_inner() returns the inner string value from Path
        invitation_id: invitation_id.into_inner(),
        password: user_data.password.clone(),
    };

    state.db.send(msg)
        .from_err()
        .and_then(|db_response| match db_response {
 Ok(slim_user) => Ok(HttpResponse::Ok().json(slim_user)),
 Err(service_error) => Ok(service_error.error_response()),
        }).responder()
}

测试您的实现

如果你有任何错误,在处理完错误后,让我们给它一个测试

curl --request POST \
 --url http://localhost:3000/invitation \
 --header 'content-type: application/json' \
 --data '{
 "email":"name@domain.com"
}'

应该返回类似的东西

{
 "id": "f87910d7-0e33-4ded-a8d8-2264800d1783",
 "email": "name@domain.com",
 "expires_at": "2018-10-27T13:02:00.909757"
}

想象一下,我们通过创建一个链接来向用户发送电子邮件,该链接包含一个供用户填写的表单。从那里我们会让我们的客户向http:// localhost:3000 / register / f87910d7-0e33-4ded-a8d8-2264800d1783发送请求。为了演示本演示,您可以使用以下测试命令测试您的应用程序。

curl --request POST \
 --url http://localhost:3000/register/f87910d7-0e33-4ded-a8d8-2264800d1783 \
 --header 'content-type: application/json' \
 --data '{"password":"password"}'

应该返回类似的东西

{
 "email": "name@domain.com"
}

再次运行该命令将导致错误

"Key (email)=(name@domain.com) already exists."

恭喜您拥有一个可以邀请,验证和创建用户的 Web 服务器,甚至可以向您发送半有用的错误消息。

我们来做 AUTH

根据 w3.org

基于令牌的身份验证系统背后的一般概念很简单。允许用户输入用户名和密码以获取允许他们获取特定资源的令牌 - 而无需使用他们的用户名和密码。一旦获得其令牌,用户就可以向远程站点提供令牌 - 其提供对特定资源的访问一段时间。

您如何选择交换该令牌可能会产生安全隐患。我非常警惕在客户端存储可由客户端 JavaScript 访问的东西。

本教程的目的是了解 Actix-web 和 rust,而不是如何防止服务器漏洞。我们将仅使用 http 的 cookie 来交换令牌。

请不要在生产中使用。

现在,这就是😰,让我们看看我们能在这里做些什么。actix-web 为我们提供了一种巧妙的方法,作为处理会话 cookie 的中间件,这里记录了actix_web :: middleware :: identity。要启用此功能,我们修改 app.rs 文件如下。

use actix_web::middleware::identity::{CookieIdentityPolicy, IdentityService};
use chrono::Duration;
//--snip
pub fn create_app(db: Addr<DbExecutor>) -> App<AppState> {
 // secret is a random 32 character long base 64 string
 let secret: String = std::env::var("SECRET_KEY").unwrap_or_else(|_| "0".repeat(32));
 let domain: String = std::env::var("DOMAIN").unwrap_or_else(|_| "localhost".to_string());

    App::with_state(AppState { db })
        .middleware(Logger::default())
        .middleware(IdentityService::new(
            CookieIdentityPolicy::new(secret.as_bytes())
                .name("auth")
                .path("/")
                .domain(domain.as_str())
                .max_age(Duration::days(1)) // just for testing
                .secure(false),
        ))
 //--snip
}

req.remember(data)req.identity()req.forget()等方便操作 HttpRequest 的路由参数。这反过来将设置和删除客户端的 cookie 身份验证。

JWT

建议使用jsonwebtoken。该 lib 现在 repo 有工作示例,我能够实现以下默认解决方案。这不是 JWT 最安全的实现,您可能希望查找资源。

创建 auth 处理程序和路由函数之前,为 util.rs 添加一些 jwt 编码和解码辅助函数。在 main.rs 加入extern crate jsonwebtoken as jwt

如果有人有更好的实施,我会很乐意接受。

// utils.rs
use models::SlimUser;
use std::convert::From;
use jwt::{decode, encode, Header, Validation};
use chrono::{Local, Duration};
//--snip

#[derive(Debug, Serialize, Deserialize)]
struct Claims {
 // issuer
    iss: String,
 // subject
    sub: String,
 //issued at
    iat: i64,
 // expiry
    exp: i64,
 // user email
    email: String,
}

// struct to get converted to token and back
impl Claims {
 fn with_email(email: &str) -> Self {
        Claims {
            iss: "localhost".into(),
            sub: "auth".into(),
            email: email.to_owned(),
            iat: Local::now().timestamp(),
            exp: (Local::now() + Duration::hours(24)).timestamp(),
        }
    }
}

impl From<Claims> for SlimUser {
 fn from(claims: Claims) -> Self {
        SlimUser { email: claims.email }
    }
}

pub fn create_token(data: &SlimUser) -> Result<String, ServiceError> {
 let claims = Claims::with_email(data.email.as_str());
 encode(&Header::default(), &claims, get_secret().as_ref())
        .map_err(|_err| ServiceError::InternalServerError)
}

pub fn decode_token(token: &str) -> Result<SlimUser, ServiceError> {
 decode::<Claims>(token, get_secret().as_ref(), &Validation::default())
        .map(|data| Ok(data.claims.into()))
        .map_err(|_err| ServiceError::Unauthorized)?
}

// take a string from env variable
fn get_secret() -> String {
    env::var("JWT_SECRET").unwrap_or("my secret".into())
}

验证处理

src/auth_handler.rs并给你 main.rs 添加mod auth_handler

//auth_handler.rs
use actix::{Handler, Message};
use diesel::prelude::*;
use errors::ServiceError;
use models::{DbExecutor, User, SlimUser};
use bcrypt::verify;
use actix_web::{FromRequest, HttpRequest, middleware::identity::RequestIdentity};

#[derive(Debug, Deserialize)]
pub struct AuthData {
 pub email: String,
 pub password: String,
}

impl Message for AuthData {
 type Result = Result<SlimUser, ServiceError>;
}


impl Handler<AuthData> for DbExecutor {
 type Result = Result<SlimUser, ServiceError>;
 fn handle(&mut self, msg: AuthData, _: &mut Self::Context) -> Self::Result {
 use schema::users::dsl::{users, email};
 let conn: &PgConnection = &self.0.get().unwrap();
 let mismatch_error = Err(ServiceError::BadRequest("Username and Password don't match".into()));

 let mut items = users
            .filter(email.eq(&msg.email))
            .load::<User>(conn)?;

 if let Some(user) = items.pop() {
 match verify(&msg.password, &user.password) {
 Ok(matching) => {
 if matching { return Ok(user.into()); } else { return mismatch_error; }
                }
 Err(_) => { return mismatch_error; }
            }
        }
        mismatch_error
    }
}

上面采用AuthData包含客户端发送的电子邮件和密码的结构。使用该电子邮件从数据库中提取用户并使用 bcrypt verify函数来匹配密码。返回用户或我们返回BadRequest错误。

src/auth_routes.rs以下内容:

// auth_routes.rs
use actix_web::{AsyncResponder, FutureResponse, HttpResponse, HttpRequest, ResponseError, Json};
use actix_web::middleware::identity::RequestIdentity;
use futures::future::Future;
use utils::create_token;

use app::AppState;
use auth_handler::AuthData;

pub fn login((auth_data, req): (Json<AuthData>, HttpRequest<AppState>))
             -> FutureResponse<HttpResponse> {
    req.state()
        .db
        .send(auth_data.into_inner())
        .from_err()
        .and_then(move |res| match res {
 Ok(slim_user) => {
 let token = create_token(&slim_user)?;
                req.remember(token);
 Ok(HttpResponse::Ok().into())
            }
 Err(err) => Ok(err.error_response()),
        }).responder()
}

pub fn logout(req: HttpRequest<AppState>) -> HttpResponse {
    req.forget();
    HttpResponse::Ok().into()
}

login 方法提取AuthData from 请求并向我们在 auth_handler.rs 中实现的DbEexcutorActor 处理程序发送消息。如果一切都很好,我们会让用户返回给我们,我们使用之前在utils.rs中定义的辅助函数来创建一个令牌和调用req.remember(token`)。这又设置了一个带有令牌的 cookie 头,供客户端保存。

最后一件事在app.rs使用我们的登录 /注销功能。将.rsource("/auth")闭包更改为以下内容:

.resource("/auth", |r| {
            r.method(Method::POST).with(login);
            r.method(Method::DELETE).with(logout);
        })

在文件的顶部添加use auth_routes::{login, logout};

试运行 AUTH

您已经创建了一个使用电子邮件和密码的用户。使用以下 curl 命令测试

curl -i --request POST \
 --url http://localhost:3000/auth \
 --header 'content-type: application/json' \
 --data '{
 "email": "name@domain.com",
 "password":"password"
}'

## response
HTTP/1.1 200 OK
set-cookie: auth=iqsB4KUUjXUjnNRl1dVx9lKiRfH24itiNdJjTAJsU4CcaetPpaSWfrNq6IIoVR5+qKPEVTrUeg==; HttpOnly; Path=/; Domain=localhost; Max-Age=86400
content-length: 0
date: Sun, 28 Oct 2018 12:36:43 GMT

如果收到了带有 set-cookie 标头的 200 响应,成功登录。

为了测试注销,我们向 /auth 它发送一个 DELETE 请求,确保你得到带有空数据和即时到期日的 set-cookie 头。

curl -i --request DELETE \
 --url http://localhost:3000/auth

## response
HTTP/1.1 200 OK
set-cookie: auth=; HttpOnly; Path=/; Domain=localhost; Max-Age=0; Expires=Fri, 27 Oct 2017 13:01:52 GMT
content-length: 0
date: Sat, 27 Oct 2018 13:01:52 GMT

实现受保护的路由

使 Auth 的意义在于验证请求是否来自经过身份验证的客户端。Actix-web 有一个FromRequest,可以在任何类型上实现,然后使用它从请求中提取数据。见文档这里。我们将在auth_handler.rs底部添加以下内容。

//auth_handler.rs
//--snip
use actix_web::FromRequest;
use utils::decode_token;
//--snip

// we need the same data as SlimUser
// simple aliasing makes the intentions clear and its more readable
pub type LoggedUser = SlimUser;

impl<S> FromRequest<S> for LoggedUser {
 type Config = ();
 type Result = Result<LoggedUser, ServiceError>;
 fn from_request(req: &HttpRequest<S>, _: &Self::Config) -> Self::Result {
 if let Some(identity) = req.identity() {
 let user: SlimUser = decode_token(&identity)?;
 return Ok(user as LoggedUser);
        }
 Err(ServiceError::Unauthorized)
    }
}

使用类型别名而不是创建一个全新的类型。当我们 LoggedUser 从请求中提取时,读者会知道它是经过身份验证的用户。FromRequest trait 只是尝试将 cookie 中的字符串反序列化为我们的结构,如果失败则只返回Unauthorized错误。为了测试这个,我们需要添加一个实际路由或 app。在auth_routes.rs添加另一个函数

//auth_routes.rs
//--snip

pub fn get_me(logged_user: LoggedUser) -> HttpResponse {
    HttpResponse::Ok().json(logged_user)
}

要调用它,我们在 app.rs 资源中注册此方法。

//app.rs
use auth_routes::{login, logout, get_me};
//--snip

.resource("/auth", |r| {
    r.method(Method::POST).with(login);
    r.method(Method::DELETE).with(logout);
    r.method(Method::GET).with(get_me);
})
//--snip

测试登录用户

在终端中尝试以下 Curl 命令。

curl -i --request POST \
 --url http://localhost:3000/auth \
 --header 'content-type: application/json' \
 --data '{
 "email": "name@domain.com",
 "password":"password"
}'
# result would be something like
HTTP/1.1 200 OK
set-cookie: auth=HdS0iPKTBL/4MpTmoUKQ5H7wft5kP7OjP6vbyd05Ex5flLvAkKd+P2GchG1jpvV6p9GQtzPEcg==; HttpOnly; Path=/; Domain=localhost; Max-Age=86400
content-length: 0
date: Sun, 28 Oct 2018 19:16:12 GMT

## and then pass the cookie back for a get request
curl -i --request GET \
 --url http://localhost:3000/auth \
 --cookie auth=HdS0iPKTBL/4MpTmoUKQ5H7wft5kP7OjP6vbyd05Ex5flLvAkKd+P2GchG1jpvV6p9GQtzPEcg==
## result
HTTP/1.1 200 OK
content-length: 27
content-type: application/json
date: Sun, 28 Oct 2018 19:21:04 GMT

{"email":"name@domain.com"}

它应该以 json 的形式成功返回您的电子邮件。只有登录的用户或具有有效 cookie 身份验证和令牌的请求才会通过您提取的LoggedUser路由。

英文原文

3289 次点击
所在节点    Rust
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/508312

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX