使用axum构建博客系统 - 鉴权与登录

发布时间 2023-10-21 20:26:06作者: CrossPython

本章实现后台管理的鉴权,以及管理员的登录、注销功能。涉及的知识点有:cookie及中间件等。

数据库结构

CREATE TABLE admins (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255) NOT NULL,
  password VARCHAR(255) NOT NULL,
  is_del BOOLEAN NOT NULL DEFAULT FALSE
);
字段说明
id 主键,唯一标识,自动编号
email 管理员邮箱
password 加密后的管理员密码
is_del 是否删除

初始数据

INSERT INTO admins(email,password) VALUES('team@axum.rs', '$2b$12$OljS3FqwxaYXESzu6F0ZRevgBrt9ueY.7NNzdsMOaJk0YoGD5aTii');

为了方便使用,我们插入一条初始数据作为默认管理员:

  • 邮箱:team@axum.rs
  • 密码:axum.rs

数据模型

// src/model.rs

#[derive(PostgresMapper, Serialize)]
#[pg_mapper(table="admins")]
pub struct Admin {
    pub id:i32,
    pub email:String,
    pub password:String,
    pub is_del:bool,
}

该数据模型的字段与数据表结构一一对应。

数据库操作

// src/db/admin.rs
pub async fn find(client:&Client, email: &str) -> Result<Admin> {
    super::query_row(client, "SELECT id,email,password,is_del FROM admins WHERE email=$1 AND is_del=false", &[&email]).await
}
  • find():通过邮箱查找对应的管理员

模板

新加的模板位于templates/backend/admin。未涉及新知识,请自行在源码仓库查看。

视图类

新加的视图类位于src/view/auth.rs。未涉及新知识,请自动在源码仓库查看。

handler

// src/handler/auth.rs

pub async fn login_ui() -> Result<HtmlView> {
    let handler_name = "auth/login_ui";
    let tmpl = Login {};
    render(tmpl).map_err(log_error(handler_name))
}

pub async fn login(
    Extension(state): Extension<Arc<AppState>>,
    Form(frm): Form<AdminLogin>,
) -> Result<RedirectView> {
    let handler_name = "auth/login";
    tracing::debug!("{}", password::hash("axum.rs")?);
    let client = get_client(&state).await.map_err(log_error(handler_name))?;
    let admin_info = admin::find(&client, &frm.email)
        .await
        .map_err(|err| match err.types {
            AppErrorType::Notfound => AppError::incorrect_login(),
            _ => err,
        })
        .map_err(log_error(handler_name))?;
    let verify =
        password::verify(&frm.password, &admin_info.password).map_err(log_error(handler_name))?;
    if !verify {
        return Err(AppError::incorrect_login());
    }
    redirect_with_cookie("/admin", Some(&admin_info.email))
}

pub async fn logout() -> Result<RedirectView> {
    redirect_with_cookie("/auth", Some(""))
}
  • login_ui():渲染登录页面
  • login():处理登录逻辑
    • 调用了password::verify()对密码进行验证。有关新增的password模块,请查看下文的“密码加密与验证”部分。
    • 调用了redirect_with_cookie()进行带cookie的跳转。该函数将在下文的Cookie部分进行说明。
  • logout():注销登录。实质是将cookie设置为空字符串。

路由

// src/handler/frontend/mod.rs
pub fn router()->Router {
    Router::new().route("/", get(index::index))
        .route("/auth", get(login_ui).post(login))
        .route("/logout", get(logout))
}

注意,基于以下两个原因,需要将登录的路由注册到前台路由上:

  • 因为前台路由的前缀是/,只有这样,登录之后设置Cookie才有效
  • 因为登录是不需要鉴权的,所以不能注册到后台路由上

中间件

// src/middleware.rs

pub struct Auth(pub String) ;
#[async_trait]
impl<B> FromRequest<B> for Auth
where
    B: Send,
{
    type Rejection = AppError;
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        let headers = req.headers().unwrap();
        let cookie = cookie::get_cookie(headers);
        let auth = cookie.unwrap_or("".to_string());
        if  auth.is_empty() {
            return Err(AppError::forbidden());
        }
        Ok(Auth(auth))
    }
}
  • 从请求头中获取cookie
  • 如果没有我们设置的cookie或者该cookie的值为空,返回AppError::forbidden(),这种错误会导致浏览器重新定向到登录页面。实现原理参见下文的AppError部分
  • cookie模块请看下文的“Cookie”部分

应用中间件

定义好中间件好,需要将它应用到后台路由上,以便保护后台管理:

// main.rs

let backend_routers = backend::router().layer(extractor_middleware::<middleware::Auth>());

密码加密与验证

// src/password.rs
pub fn hash(pwd: &str) -> Result<String> {
    bcrypt::hash(pwd, DEFAULT_COST).map_err(AppError::from)
}
pub fn verify(pwd: &str, hashed_pwd: &str) -> Result<bool> {
    bcrypt::verify(pwd, hashed_pwd).map_err(AppError::from)
}

AppError

为了处理管理员登录和鉴权,对AppError进行了大量改动。

// src/error.rs

#[derive(Debug)]
pub enum AppErrorType {
   	//...
    Crypt,
    IncorrectLogin,
    Forbidden,
}

impl AppError {
    // ...
    pub fn incorrect_login() -> Self {
        Self::from_str("错误的邮箱或密码", AppErrorType::IncorrectLogin)
    }
    pub fn forbidden() -> Self {
        Self::from_str("无权访问", AppErrorType::Forbidden)
    }
    pub fn response(self) -> axum::response::Response {
        match self.types {
            AppErrorType::Forbidden  => {
                let mut hm = HeaderMap::new();
                hm.insert(header::LOCATION, "/auth".parse().unwrap());
                (StatusCode::FOUND, hm, ()).into_response()
            }
            _ => self
                .message
                .to_owned()
                .unwrap_or("有错误发生".to_string())
                .into_response(),
        }
    }
}
// ...
impl From<bcrypt::BcryptError> for AppError {
    fn from(err: bcrypt::BcryptError) -> Self {
        Self::from_err(Box::new(err), AppErrorType::Crypt)
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> axum::response::Response {
        self.response()
    }
}
  • AppErrorType::Crypt:密码加密/验证失败
  • AppErrorType::IncorrectLogin:错误的邮箱/密码
  • AppErrorType::Forbidden:禁止访问,请先登录
  • response(self):根据不同的错误类型作出不同响应。其中,如果是 AppErrorType::Forbidden(即:未登录),跳转到登录页面;其它情况直接输出错误信息。
  • impl From<bcrypt::BcryptError> for AppError:实现bcrypt::BcryptErrorAppError的转换
  • impl IntoResponse for AppError:改由response(self)方法提供

Cookie

cookie模块

// src/cookie.rs

const COOKIE_NAME: &str = "axum_rs_blog_admin";

pub fn get_cookie(headers: &HeaderMap) -> Option<String> {
    let cookie = headers
        .get(axum::http::header::COOKIE)
        .and_then(|value| value.to_str().ok())
        .map(|value| value.to_string());
    match cookie {
        Some(cookie) => {
            let cookie = cookie.as_str();
            let cs: Vec<&str> = cookie.split(';').collect();
            for item in cs {
                let item: Vec<&str> = item.split('=').collect();
                if item.len() != 2 {
                    continue;
                }
                let key = item[0];
                let val = item[1];
                let key = key.trim();
                let val = val.trim();
                if key == COOKIE_NAME {
                    return Some(val.to_string());
                }
            }
            None
        }
        None => None,
    }
}
pub fn set_cookie(value: &str) -> HeaderMap {
    let c = format!("{}={}", COOKIE_NAME, value);
    let mut hm = HeaderMap::new();
    hm.insert(axum::http::header::SET_COOKIE, (&c).parse().unwrap());
    hm
}
  • COOKIE_NAME:本项目使用的Cookie的名称
  • get_cookie():从请求头获取Cookie
  • set_cookie():设置Cookie,并将带有cookie的响应头返回

redirect_with_cookie()

// src/handler/mod.rs
fn redirect_with_cookie(url: &str, c:Option<&str>) -> Result<RedirectView> {
    let mut hm = match c {
        Some(s) => cookie::set_cookie(s),
        None => HeaderMap::new(),
    };
    hm.insert(header::LOCATION, url.parse().unwrap());
    Ok((StatusCode::FOUND, hm, ()))
}

通过参数c:Option<&str>判断是否需要设置Cookie。如果需要,则调用cookie::set_cookie(s)来获得一个带cookie的响应头;如果不需要,则调用HeaderMap::new()生成一个空的响应头。

最后,在响应头里设置跳转。

相应的,之前的redirect()可以改为调用redirect_with_cookie()来实现。

// src/handler/mod.rs
fn redirect(url: &str) -> Result<RedirectView> {
    redirect_with_cookie(url, None)
}