Closed40

自作のユーザー認証システムを作る

榊原ライカ榊原ライカ

予算がないのと技術的に面白そうだったからよくある認証システムをRustで作ってみる。

榊原ライカ榊原ライカ

今回のプロジェクトにおいて、認証システムという観点からセキュリティ的にソースコードを公開するべきなのかよく分からない。
一応Privateにしておいて、見せてもよさそうなところだけこのスクラップに上げておこうと思う。

榊原ライカ榊原ライカ

動機は書いたけどそういえば目的を書いてなかった。

今回、VTuberがYoutubeで取っている配信予定枠を自動回収してJSON形式で公開するWebAPI「matatabi」を作った。
WebAPIとしては一応公開できる状態にはなっているけど、家に転がってるサーバーが貧弱だから多分大量にリクエストが来たら速攻で鯖から煙が出ると思う。
だから一応の制限として使用するのにアカウントを要求させるようにするために今回のシステムを作ってみる。

榊原ライカ榊原ライカ

正直Twitterでの認証とか(ODICって名前だっけ)を使うのが一番早いのだろうけど、これまでのプロジェクトで外部サービスへの依存がない[1]しせっかくだから自己完結出来るようにしてみたい。

脚注
  1. 仕様上Youtube Data API v3には依存してるけど、それはそれ、これはこれ。 ↩︎

榊原ライカ榊原ライカ

今のところの依存関係はこう。

Cargo.toml
[dependencies]
tokio = { version = "1.12.0", features = ["full"] }
axum = "0.5.16"
tower-http = { version = "0.3", features = ["trace"] }
sqlx = { version = "0.6.2", features = ["runtime-tokio-native-tls", "postgres", "chrono"] }

tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["registry", "fmt", "env-filter"] }
tracing-appender = "0.2"

dotenvy = "0.15.3"
anyhow = "1.0"
thiserror = "1.0.30"
serde = { version = "1.0.130", features = ["derive"] }
serde_json = "1.0.72"
chrono = { version = "0.4.19", features = ["serde"] }

依存関係って公開してもいい情報なのかな…

榊原ライカ榊原ライカ

セキュリティ性と冗長性が求められるシステムでChrono使うのはちょっとあれだけど代替を知らないから取り敢えずこのままで。

Potential segfault in the time crate
RUSTSEC-2020-0071

榊原ライカ榊原ライカ

最初はとにかくユーザー情報がないと始まらないということでユーザー登録のプロセスを考えてみる

榊原ライカ榊原ライカ

考えることより先にざっとした試作品を作ることにする。(考えるの嫌い)

榊原ライカ榊原ライカ

ユーザー登録フォームをHtmlでなんとなくつくった。
これは開発コンソールでどうせ見えるし出していいでしょう。

ユーザー登録フォーム(CSSがないから超安っぽい)

registration_form.html
<!doctype html>
<html>
  <head>
        
  </head>
  <body>
    <div class="title">
      <h1>SignUp</h1>
      <span>Powered by <a href="">Cage Auth Service</a></span>
    </div>
    <div class="form-container">
      <h2>Create your Account.</h2>
      <form action="/reg" method="post">
        <label for="email">
          <input type="text" name="email" placeholder="email@example.com" required="required">
        </label>
        <label for="username">
          <input type="text" name="username" placeholder="username" required="required">
        </label>
        <label for="password">
          <input type="password" name="password" placeholder="password" required="required">
        </label>
        <div class="agreement-container">
          <input type="checkbox" id="check-user-agreement" class="checkbox" required="required">
          <span>I Agree <a href="http://reiva.dev">UserAgreement</a></span>
        </div>
        <input class="register" type="submit" value="Register">
      </form>
    </div>
  </body>
</html>
榊原ライカ榊原ライカ

そういえばaxumでcssを適用したページを出すのってどうすればいいんだろう。

榊原ライカ榊原ライカ

今のところ分かっている問題点を上げると、
・まず、このままだともちろんダミーデータで全部通ってしまう。
・重複チェックをこの画面で行うとリスト型攻撃のリスクがある。

この2点が速攻で分かる。

榊原ライカ榊原ライカ

対策としてメアド認証をしてから個人情報入力に誘導するようにする。
そのためにメールライブラリの「lettre」を追加。

Cargo.toml
[dependencies]
lettre = { version = "0.10.1", features = ["tokio1-native-tls", "tracing"] }
榊原ライカ榊原ライカ

よく見るサービスのフローはこんな感じだろうか。
サービスは色々あるけど、なんとなくSteamを参考にしてみた。


 ┌──────────────┐                                             ┌──────────────┐
 │              │  Connect                                    │              │
 │              ├────────────────────────────────────────────►│              │
 │              │                               Save Session  │              │
 │              │◄────────────────────────────────────────────┤              │
 │              │  Send Registration Email Adrress            │              │
 │              ├────────────────────────────────────────────►│              │
 │              │                 Send confirm mail with PIN  │              │
 │              │◄────────────────────────────────────────────┤              │
 │     User     │                    Show PIN confirm screen  │    Server    │
 │              │◄────────────────────────────────────────────┤              │
 │              │  Send PIN Code                              │              │
 │              ├────────────────────────────────────────────►│              │
 │              │              Show User Registration Screen  │              │
 │              │◄────────────────────────────────────────────┤              │
 │              │  Input user data                            │              │
 │              ├────────────────────────────────────────────►│              │
 │              │                                             │              │
 └──────────────┘                                             └──────────────┘
榊原ライカ榊原ライカ

セッションを保存するのにPostgresはオーバーキルだからsqlite featureを追加。

Cargo.toml
sqlx = { version = "0.6.2", features = ["runtime-tokio-native-tls", "postgres", "sqlite", "chrono"] }
榊原ライカ榊原ライカ

メールを飛ばしたいが、ドメインの設定をしてなかったから反映待ち。
しばらくしたら再開

榊原ライカ榊原ライカ

RedBullキメてたらいつの間にか反映されてた。さすがCloudflare早いぜ。
早速lettreをサンプルを参考にしながら使っていく。

榊原ライカ榊原ライカ

同時にフォームを修正。
初めのスクリーンはメアド要求のみにした。

榊原ライカ榊原ライカ

実装できたから試しに自分のメアドに送ってみる。
取り敢えずメアドを送るだけのテストだからサーバー側の最終結果は/にリダイレクトされるようにした。
サンプルそのままだから抜粋して貼っておく。

register/email.rs
pub async fn registration(Form(input): Form<UserInput>) -> impl IntoResponse {
    dbg!(&input);
    let confirm_mail = Message::builder()
        .from("support <support@reiva.dev>".parse().unwrap())
        .to((format!("you <{}>", &input.email)).parse().unwrap())
        .subject("Please confirm PIN code")
        .body(String::from("yeah"))
        .unwrap();

    let creds = Credentials::new("SMTP_USER".to_owned(), "SMTP_PASS".to_owned());

    let mailer = SmtpTransport::relay("smtp.gmail.com")
        .unwrap()
        .credentials(creds)
        .build();
    
    match mailer.send(&confirm_mail) {
        Ok(_) => (),
        Err(reason) => tracing::error!("{:?}", reason.to_string())
    }

    Redirect::to("/")
}
榊原ライカ榊原ライカ

Thunderbirdで確認したところ無事受信に成功していた。
正直SMTP周りで一回詰まると思ったけど意外にも一発で通った。

yeah...

榊原ライカ榊原ライカ

次はセッションをCookieを使って保存できるようにする。
具体的には前のフロー通り、接続されたらユーザーのクライアントCookieとサーバー側のsqliteのデータベースにユニークな識別子を保存して、PIN入力時に検証する。
これによってサーバー側はユーザーが同じものであると判別できて、同時に攻撃者ではないことを証明できる(はず)

榊原ライカ榊原ライカ

もしかしたらだけどsqliteじゃなくてさらに高速なRedisを使う方が正解かもしれない?
検証が成功すれば消すことになるから永続性ではなく速度を取る方向がいいかも。
あとで考えておく。

榊原ライカ榊原ライカ

昼寝をしていたらいつの間にかこんな時間になってしまった。
というわけでsqliteの代わりにredisを導入する。多分そんなにコストはかかってない(はず)
コネクションプールには一番扱いやすそうなbb8bb8-redisを使う。

Cargo.toml
[dependencies]
redis = { version = "0.21.6", features = ["tokio-comp"] }
bb8-redis = "0.11.0"
榊原ライカ榊原ライカ

bb8-redisはredisをオーバーラッピングしている感じっぽいからredisは除けておく。

榊原ライカ榊原ライカ

と、思ったけどなんか挙動があやレい。
bb8-redisじゃなくてbb8とredisを分けて導入して自分でPool処理を書いてみる。

榊原ライカ榊原ライカ

bb8-redisとほぼ実装が同じだけどredis::Client::open::(T: IntoConnectionInfo)を外に出した。
意味があるのかわからないけど私自身が分かりやすいからこれで。

redis.rs
use bb8::Pool;

pub type RedisPool = bb8::Pool<RedisConnectionManager>;

pub async fn connect() -> anyhow::Result<Pool<RedisConnectionManager>, redis::RedisError> {
    let client = redis::Client::open(
        dotenvy::var("REDIS_URL")
            .map_err(|_| tracing::error!("you must set REDIS_URL."))
            .unwrap())?;
    tracing::info!("redis client opened");

    let manager = RedisConnectionManager::new(client);
    tracing::info!("connection manager ready");

    let pool = Pool::builder()
        .max_size(
            dotenvy::var("REDIS_MAX_CONNECTIONS")
                .ok()
                .and_then(|max| max.parse().ok())
                .unwrap_or(4)
        )
        .min_idle(
            dotenvy::var("REDIS_MIN_CONNECTIONS")
                .ok()
                .and_then(|min| min.parse().ok())
        )
        .max_lifetime(Some(std::time::Duration::from_millis(500)))
        .build(manager)
        .await?;
    tracing::info!("redis connection pool built to ready.");
    Ok(pool)
}

#[derive(Debug, Clone)]
pub struct RedisConnectionManager {
    client: redis::Client
}

impl RedisConnectionManager {
    pub fn new(client: redis::Client) -> Self {
        Self { client }
    }
}

#[async_trait::async_trait]
impl bb8::ManageConnection for RedisConnectionManager {
    type Connection = redis::aio::Connection;
    type Error = redis::RedisError;

    async fn connect(&self) -> Result<Self::Connection, Self::Error> {
        self.client.get_async_connection().await
    }

    async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
        let pong: String = redis::cmd("PING").query_async(conn).await?;
        match pong.as_str() {
            "PONG" => Ok(()),
            _ => Err((redis::ErrorKind::ResponseError, "ping request").into()),
        }
    }

    fn has_broken(&self, _: &mut Self::Connection) -> bool {
        false
    }
}
榊原ライカ榊原ライカ

さて、投稿する期間が大分空いたけど取り敢えずサインアップ処理が出来たからまとめていく。

榊原ライカ榊原ライカ

サーバーのサインアップ用のルートはこんな感じ。

/signup
/signup/verify/:instance_id
/signup/registration/:instance_id

:instance_idっていうのはaxumのPathで値を受けられるようにしてるから。(axum本体の説明は省く)

榊原ライカ榊原ライカ

まず、ユーザーは/signupルートにアクセスしてメアドの認証をしてもらう。

榊原ライカ榊原ライカ

Thunderbirdで見るとメールはこんな感じ。
PINコードだけがCSSのuser-select属性によって選択が可能にしてある。

榊原ライカ榊原ライカ

して、メールの認証が終われば無事ユーザー登録フォームに案内される。

榊原ライカ榊原ライカ

この一連の作業は10分以内に終わらせないとセッション有効期限が切れてエラーメッセージが出てくる

榊原ライカ榊原ライカ

ユーザーに見えるのはこれだけ。
かなりシンプルだけど裏が結構ごっちゃごちゃなので公開していいところだけ。

榊原ライカ榊原ライカ

実際のコードを挙げずに簡単なもので説明する。
まず、/signupにアクセスした際にFromRequest<B>を使ってセッション用のイベントを作る。
実装する先は構造体ではなく列挙体。こっちの方が明確に処理を分けることができるから。

session_test.rs
pub enum SignupSession {
}

#[async_trait::async_trait]
impl<B: Send, I: IntoResponse> FromRequest<B> for SignupSession {
    type Rejection = (StatusCode, T);
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        todo!()
    }
}
榊原ライカ榊原ライカ

有効期限付きのセッションで考えられる出来事は、「初作業」「作業再開」「期限切れ」の三つだと思う…
だからenumにそれぞれに合ったものを足していく。

session_test.rs
pub enum SignupSession {
    NewUser,
    Resume,
    Expired,
}
このスクラップは2022/10/13にクローズされました