😸

Rust製ログイン機能付きWebフレームワーク「ares」の背景と思想

に公開

― Rust製ログイン機能付きWebフレームワークaresの背景と思想 ―
「RustでもLaravelのようにログイン機能付きのWeb開発ができたら」
そんな思いから、Webフレームワーク「ares」の開発を始めました。
Rustは安全性、速度、並行性のすべてを高度に両立できる言語です。


なぜRustでWebフレームワークを自作したのか?

理由① 既存のRust Webフレームワークにログインライブラリがない

RustのWebフレームワークAxumやActix WebにはLaravel(PHP)やDjango(Python)のようなログイン機能のライブラリがありません。そのため、AxumやActixでWebアプリケーションを構築する場合、自分自身でログイン機能を実装しなければなりませんが、ゼロからスクラッチで作るのは多分に骨の折れる作業です。また、調査して実装したとしても、それが果たしてベストプラクティスであるかどうかはわかりません。そのため、ログイン機能がデフォルトで付いているRust Webフレームワークを作りたかった。

理由② 既存フレームワークの抽象化がやや「過剰」

既存のRustフレームワークは機能的には優れていますが、抽象化が進みすぎて、内部の挙動がわかりにくいと感じることがあります。そこで、自らの手で制御しやすく、教育的にも学びやすいものを目指しました。

理由③ Rustの言語特性

Rustは安全性、速度、並行性の全てを高度に両立できる言語と言われています。C/C++のような性能を持ちながら、コンパイル時にバグを潰せる強力な型システムと所有権モデルを備えています。
このような言語特性を活かすことで、高速かつ堅牢なアプリケーションを構築できる可能性があります。

理由④ Webフレームワークの仕組みの理解

バックエンドエンジニアを目指す人のためのRust(著:安藤一慈他)」の第7章 「自作ライブラリを公開できるようになろう」に触発されて、自分もライブラリを作りたくなりました。また、フレームワーク/ライブラリ開発に取り組むことで、他のWebフレームワークやcrateの仕組みの理解が深まると思いました。


フレームワーク「ares」の概要

ares は、Rustで実装した軽量なWebフレームワークです。
以下のような基本機能を備えています。

  • ルーティング
    -- Get(データ取得), Post(データ送信), Address binding, ログイン制限のパス, リダイレクト
  • テンプレートエンジンレンダリング
    -- HTMLファイルのRendering, handlerでの変数受け渡し, JS, CSSなどの静的ファイル利用, HTMLエスケープ
  • ログイン認証
    -- サイイン、ログイン、ログアウト、session_token, csrf_token, 匿名セッション
  • データベース接続
    -- マイグレーション(users, sessionsテーブル生成), sessionsレコードの自動破棄
  • json
    -- content-typeがapplication/jsonの対応
  • ファイル送信
    -- content-typeがmultipart/form-dataの対応

ルーティング

GET

axumのルーティングのように、main関数の中で、パスとハンドラー(e.g. handle_index)を指定し、ハンドラーの中で、テンプレートに渡す変数を指定できます。以下の例で、http://192.168.33.10:8000/index.html にアクセスできるようになります。

use std::collections::HashMap;
mod ares;
use ares::{Router, HttpResponse, render_template_from_file};

fn main() {
    let mut router = Router::new();
    router.get("index", handle_index);
    let needs_auth = None;
    router.up("192.168.33.10:8000", needs_auth);
}

fn handle_index() -> HttpResponse {
    let mut content = HashMap::new();
    content.insert(
        "title",
        "This is index page!",
    );
    let html = render_template_from_file("index", &content);
    HttpResponse::new(200, &html)
}
POST

POSTリクエストも、GETリクエスト同様にmain関数の中で、受け取るパスとハンドラー(e.g. handle_api_post)を指定し、POSTのハンドラーは1.リクエストヘッダー、2.bodyの参照付きスライス(&str), 3. bodyのu8型スライス(&[u8])を引数とし、ハンドラーの中で受け取ったデータの処理を行います。以下の例ではjsonデータのPOSTを示しています。
content-typeが application/x-www-form-urlencoded, application/jsonの場合は参照付きスライス(&str)でデータを受け取り、content-typeが multipart/form-data の場合はバイト型であるu8型スライス(&[u8])で受け取ります。

use std::collections::HashMap;
mod ares;
use ares::{Router, HttpResponse, parse_json};

fn main() {
    let mut router = Router::new();
    router.post("api", handle_api_post);
    let needs_auth = None;
    router.up("192.168.33.10:8000", needs_auth);
}

fn handle_api_post(_req: &str, body: &str, _body_bytes: &[u8]) -> HttpResponse {
    let data = parse_json(&body).unwrap_or_default();

    let binding = "<unknown>".to_string();
    let name = data.get("name").unwrap_or(&binding);
    let message = format!("{{\"greeting\": \"Hello, {}!\"}}", name);

    let mut response = HttpResponse::new(200, &message);
    response.headers.insert("Content-Type".to_string(), "application/json".to_string());
    response
}
Address Binding

main関数の router.up の中で、IPアドレスとポートを指定します。これは、ライブラリ側で、tokio::net::TcpListener::bind("192.168.33.10:3333").await.unwrap(); を実行していることに他なりません。

mod ares;
use ares::{Router};

fn main() {
    let mut router = Router::new();
    let needs_auth = None;
    router.up("192.168.33.10:3333", needs_auth);
}
ログイン制限のパス指定

router.up の第二引数にvectorでログイン後のみ閲覧できるパスを指定することができます。
下記の例では、http://0.0.0.0:8000/indexhttp://0.0.0.0:8000/hello はログイン後しかアクセスできません。ログアウト後もしくはログイン前にアクセスした場合は、*/login にリダイレクトされます。Axumなどでログイン機能を実装する場合、ログイン制限のミドルウェアも自分自身でゼロから作成しなければなりませんが、本フレームワークでは、パスの指定だけで制限できるように簡略化しています。

fn main() {
    // 省略
    let needs_auth = Some(vec!["/index", "/hello"]);
    router.up("0.0.0.0:8000", needs_auth);
}
リダイレクト

ハンドラーの中で、redirect()の引数にパスを指定することで、そのパスへのリダイレクト処理を実装することができます。

mod ares;
use ares::{redirect};

fn handle_test(_request: &str, _body: &str, _body_bytes: &[u8]) -> HttpResponse {
    redirect("/index");

    HttpResponse::new(200, "<h1>This is Test!</h1>")
}

HttpResponse

GET, POSTなどの各ハンドラーは返り値にStatus Code, Header, BodyのHttpResponseを返却します。

ライブラリ側で、HttpResponseは以下のように定義しています。デフォルトではContent-Typeはtext/htmlですが、ハンドラー側でjsonなどに自由にカスタマイズ可能です。

pub struct HttpResponse {
    pub status_code: u16,
    pub headers: HashMap<String, String>,
    pub body: String,
}

impl HttpResponse {
    pub fn new(status_code: u16, body: &str) -> Self {
        let mut headers = HashMap::new();
        headers.insert("Content-Type".to_string(), "text/html; charset=utf-8".to_string());

        HttpResponse {
            status_code,
            headers,
            body: body.to_string(),
        }
    }
}

レンダリング

htmlファイル

templatesディレクトの中に、パス名と同じhtmlファイル名で作成します。

templates/
 ├── index.html
 ├── hello.html
 ├── login.html
 └── logout.html
CSS, JSファイル

staticディレクトの中に作成します。

static/
 ├── app.js
 └── styles.css
handlerからhtmlファイルの呼び出し

render_template_from_file の引数に、HashMapのデータを挿入することで、サーバ側からHTML側へデータを渡すことができます。

fn handle_hello() -> HttpResponse {
    let mut data = HashMap::new();
    data.insert("name", "taro");
    let html = render_template_from_file("hello", &data);
    HttpResponse::new(200, &html)
}
  • HTML側でのデータの受け取り
    -- {{ }} の中にサーバ側で渡すHashMapデータのKeyを指定します。
<h1>Welcome to the Rust server!</h1>
<p>hello, {{ name }}</p><br>
<a href="/index">リンク: index</a><br>

  • handlerでhtmlファイルを呼び出さない場合
    -- render_template_from_file_emptyを使用します。この場合は、templatesディレクトリにパス名のhtmlファイルを作成する必要はありません。
fn handle_logout() -> HttpResponse {
    let html = render_template_from_file_empty("logout");
    HttpResponse::new(200, &html)
}
htmlファイルからCSS, JSファイルの呼び出し

相対パスで /static/* と書くことで、CSS、JSファイルを呼び出せます。

<link rel="stylesheet" href="/static/styles.css">
<script src="/static/app.js"></script>
<h1>Welcome to the Rust server!</h1>
<p>hello, {{ name }}</p><br>
<a href="/index">リンク: index</a><br>

ログイン認証

認証の仕組み(CSRF/セッション)

サインアップ

サインイン画面(/signin)をGetで表示するタイミングでサーバ側(ライブラリ)でCSRFトークン、session tokenを生成し、データベースのsessionsテーブルにそのセットを保存します。
そして、CSRFトークンはhtmlのformにセット、session tokenはCookieにセットします。

サインアップ画面(/post)にPOSTされたタイミングで、FormDataのCSRFトークンとリクエストヘッダのCookieのsession tokenを取得して、sessionsテーブルのCSRFトークン、session tokenの値と一致しているか認証します。

ログイン

ログイン画面(/login)をGetで表示するタイミングでサーバ側(ライブラリ)でCSRFトークン、session tokenを生成し、データベースのsessionsテーブルにそのセットを保存します。
そして、CSRFトークンはhtmlのformにセット、session tokenはCookieにセットします。(サインアップと同じ)

ログアップ画面(/logup)にPOSTされたタイミングで、FormDataのCSRFトークンとリクエストヘッダのCookieのsession tokenを取得して、sessionsテーブルのCSRFトークン、session tokenの値と一致しているか認証します。

また、Postされたusername, passwordがusersテーブルの値と一致しているかも併せて認証します。

ログアウト

ログアウト画面(/logout)をGetするタイミングでサーバ側(ライブラリ)でRequest Headerのsession tokenの値を取得し、データベースのsessionsテーブルからその値のレコードを削除します。
そして、Cookieのsession tokenはdeletedにセットします。こうすることで、ログイン中に閲覧できる画面は閲覧できなくなります。

PostgreSQLでのテーブル設計

PostgreSQLで以下のようなusersテーブルと、sessionsテーブルを作成します。

CREATE TABLE ares_users (​
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,​
    username text NOT NULL UNIQUE,​
    password text NOT NULL);
CREATE TABLE ares_sessions (​
    id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,​
    session_token TEXT,​
    csrf_token TEXT,​
    is_authenticated BOOLEAN DEFAULT FALSE,​
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);

サインインの処理フロー

main.rsのsiginハンドラー(GET)

create_csrf_token関数を呼び出すことで、ライブラリ側で、CSRFトークン、session tokenを生成し、データベースのsessionsテーブルにそのセットを保存、session tokenをCookieにセットを自動的に行い、返り値にCSRFトークンとHttpResponse構造体のresponseが戻ってきます。
返り値のCSRFトークンはhtmlファイルに渡してあげます。

use ares::{create_csrf_token, HttpResponse}
// 
fn handle_signin() -> HttpResponse {
    let (csrf_token, mut response) = create_csrf_token();
    let mut context = HashMap::new();
    context.insert("csrf_token", csrf_token);

    let html = render_template_from_file("signin", &context);
    response.body = html;
    response
}
signin.html

CSRFトークンをhiddenでformにセットします。

<form action="/signup" method="post">
    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
    <div class="container">
      <label for="uname"><b>Username</b></label>
      <input type="text" placeholder="Enter Username" name="name" required><br>
  
      <label for="psw"><b>Password</b></label>
      <input type="password" placeholder="Enter Password" name="password" required><br><br>
  
      <button type="submit">Sign in</button>
    </div>
</form>
main.rsのsignupハンドラー(POST)

formからPostされたデータはライブラリのparse関数で取得します。Cookieのセッショントークンはライブラリのextract_cookie_token関数で取得します。
signup関数に、formから取得したname, passwordと、extract_cookie_tokenで取得したsession_token, suinup後に遷移したいパス(redricet_path)の4つ引数にして渡します。その後のサインアップ認証やusersテーブルへのname, passwordの挿入などはライブラリ側で実行します。
signup関数を用いることで、認証処理を簡略化しています。

use ares::{parse, signup, extract_cookie_token, HttpResponse}
// 
fn handle_signup(request: &str, body: &str, _body_bytes: &[u8]) -> HttpResponse {
    let form =  parse(&body);
    let binding = "<unknown>".to_string();
    let name = form.get("name").unwrap_or(&binding);
    let password = form.get("password").unwrap_or(&binding); 
    let session_token = extract_cookie_token(request);
    let redirect_path = "/index";

    signup(name, password, session_token, redirect_path)
}

ログインの処理フロー

サインインと流れは殆ど同じです。logup関数を用いることで、認証処理を簡略化しています。

main.rsのloginハンドラー(GET)

create_csrf_token関数を呼び出すことで、ライブラリ側で、CSRFトークン、session tokenを生成し、データベースのsessionsテーブルにそのセットを保存、session tokenをCookieにセットを自動的に行い、返り値にCSRFトークンとHttpResponse構造体のresponseが戻ってきます。
返り値のCSRFトークンはhtmlファイルに渡してあげます。

use ares::{create_csrf_token, HttpResponse}
// 
fn handle_login() -> HttpResponse {
    let (csrf_token, mut response) = create_csrf_token();
    let mut context = HashMap::new();
    context.insert("csrf_token", csrf_token);

    let html = render_template_from_file("login", &context);
    response.body = html;
    response
}
login.html

CSRFトークンをhiddenでformにセットします。

<form action="/logup" method="post">
    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
    <div class="container">
      <label for="uname"><b>Username</b></label>
      <input type="text" placeholder="Enter Username" name="name" required><br>
  
      <label for="psw"><b>Password</b></label>
      <input type="password" placeholder="Enter Password" name="password" required><br><br>
  
      <button type="submit">Login</button>
    </div>
</form>
main.rsのlogupハンドラー(POST)

formからPostされたデータはライブラリのparse関数で取得します。Cookieのセッショントークンはライブラリのextract_cookie_token関数で取得します。
logup関数に、formから取得したname, passwordと、extract_cookie_tokenで取得したsession_token, logup後に遷移したいパス(login_success_path)、logup失敗時に遷移したいパス(login_failure_path)の5つ引数にして渡します。その後のsession token, cookieの認証やname, password認証などはライブラリ側で実行します。

use ares::{parse, logup, extract_cookie_token, HttpResponse}
// 
fn handle_logup(request: &str, body: &str, _body_bytes: &[u8]) -> HttpResponse {
    let form =  parse(&body);
    let binding = "<unknown>".to_string();
    let name = form.get("name").unwrap_or(&binding);
    let password = form.get("password").unwrap_or(&binding);
    let session_token = extract_cookie_token(request);

    let login_success_path = "/index";
    let login_failure_path = "/login";

    logup(name, password, session_token, login_success_path, login_failure_path)
}

自動セッション削除(pg_cron)

psqlにpg_cronでセッションレコードを削除するバッチ処理を設定することで、sessionsテーブルのレコードが溜まり続けることを防ぎます。

$ sudo apt install postgresql-14-cron
$ sudo vi /etc/postgresql/14/main/postgresql.conf

以下を追加
shared_preload_libraries = ‘pg_cron’
cron.database_name = ‘postgres’

$ sudo systemctl restart postgresql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule('delete_expired_sessions', '0 * * * *', $$​
  DELETE FROM ares_sessions​
  WHERE created_at < NOW() - INTERVAL '1 day'​
$$);

json

parse_jsonを使用してbodyからデータを取得します。内部的には、serde_json::from_str(body)の処理を行っています。

use ares::{parse_json}

fn handle_api_post(_req: &str, body: &str, _body_bytes: &[u8]) -> HttpResponse {
    let data = parse_json(&body).unwrap_or_default();

    let binding = "<unknown>".to_string();
    let name = data.get("name").unwrap_or(&binding);
    let message = format!("{{\"greeting\": \"Hello, {}!\"}}", name);

    let mut response = HttpResponse::new(200, &message);
    response.headers.insert("Content-Type".to_string(), "application/json".to_string());
    response
}

multipart

form.html
<form method="POST" action="/upload" enctype="multipart/form-data">
    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
    <input type="file" name="avatar">
    <input type="submit">
</form>
main.rs

以下の例では、POSTで受け取った画像データを uploads フォルダに保存しています。ファイルなどのmultipartは、Bytesデータとして扱うため、handle_upload関数で受け取った &[u8] の方を使用します。

use ares::{Router, HttpResponse, extract_boundary, parse_multipart_raw};
fn main() {
    let mut router = Router::new();
    router.get("form", handle_form);
    router.post("upload", handle_upload);

    let needs_auth = None;
    router.up("192.168.33.10:8000", needs_auth);
}

fn handle_upload(req: &str, _body: &str, body_bytes: &[u8]) -> HttpResponse {
    let boundary = match extract_boundary(req) {
        Some(b) => b,
        None => return HttpResponse::new(400, "Bad Request: No boundary"),
    };
    let parts = parse_multipart_raw(body_bytes, &boundary); 

    if let Some(file_bytes) = parts.get("avatar_file_data") {
        use std::fs::File;
        use std::io::Write;

        let filename = parts
            .get("avatar_filename")
            .map(|v| String::from_utf8_lossy(v).to_string())
            .unwrap_or_else(|| "upload.jpg".to_string());

        let path = format!("./uploads/{}", sanitize_filename::sanitize(filename));

        match File::create(&path) {
            Ok(mut file) => {
                if let Err(e) = file.write_all(file_bytes) {
                    return HttpResponse::new(500, &format!("Failed to save file: {}", e));
                }
            }
            Err(e) => return HttpResponse::new(500, &format!("Cannot create file: {}", e)),
        }

        HttpResponse::new(200, &format!("File uploaded as {}", path))
    } else {
        HttpResponse::new(400, "No file uploaded")
    }
}

社会実装を見据えた課題

Async対応

tokio/awaitにより非同期でアクセスできるようにしたいが、今の設計を崩さずに実装していくのはかなり複雑になりそうで、大きな課題となっています。

このaresは、まだ生まれたばかりのプロジェクトです。しかし、私にとっては単なる趣味ではありません。
「RustによるWeb開発をもっと身近に、もっと透明に」
そんな願いを込めて、今後も以下の展開を視野に入れています

  • 非同期の実装
  • チュートリアルやサンプルの整備
  • CONTRIBUTING.mdの公開

協力者・フィードバック歓迎!

aresはオープンソースです。興味を持ってくださった方、使ってみたい・一緒に作りたいという方は、ぜひGitHubでStar・Issue・PRをお待ちしています!

また、以下の観点などでのフィードバック歓迎です:

  • ドキュメントが読みにくい/わかりづらい
  • こんな機能が欲しい
  • チュートリアルが欲しい
  • この部分がバグっぽい? など

⭐ GitHubでStar・Issue・PRを歓迎します
 👉 https://github.com/cmc-labo/ares

🐦 X(旧Twitter)でも気軽に声かけてください
 👉 @knbzyh


おわりに

「フレームワーク自作」と聞くと、非効率に思えるかもしれません。しかし私にとっては、Rustを学習する旅の道草でもあります。

aresが、RustでWebアプリケーションを開発されている方や、これからやってみたい方の一助になれば嬉しいです。


✍️ GitHub: https://github.com/cmc-labo/ares
🎥 YouTube解説: https://www.youtube.com/watch?v=DQOtz1tZe24

Discussion