鉴权与登录

本章实现后台管理的鉴权,以及管理员的登录、注销功能。涉及的知识点有: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('[email protected]', '$2b$12$OljS3FqwxaYXESzu6F0ZRevgBrt9ueY.7NNzdsMOaJk0YoGD5aTii');

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

数据模型

// 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

  • 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))
    }
}

应用中间件

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

// 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()
    }
}
// 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)
}

本章代码位于05/鉴权与登录分支。

要查看完整内容,请先登录