鉴权与登录

546038
2022/03/26 23:24:23

本章实现后台管理的鉴权,以及管理员的登录、注销功能。涉及的知识点有: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

// 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才有效
  • 因为登录是不需要鉴权的,所以不能注册到后台路由上

中间件

  • 从请求头中获取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

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

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