自作のユーザー認証システムを作る
予算がないのと技術的に面白そうだったからよくある認証システムをRustで作ってみる。
取り敢えずaxumを使っておく
[dependencies]
axum = "0.5.16"
今回のプロジェクトにおいて、認証システムという観点からセキュリティ的にソースコードを公開するべきなのかよく分からない。
一応Privateにしておいて、見せてもよさそうなところだけこのスクラップに上げておこうと思う。
動機は書いたけどそういえば目的を書いてなかった。
今回、VTuberがYoutubeで取っている配信予定枠を自動回収してJSON形式で公開するWebAPI「matatabi」を作った。
WebAPIとしては一応公開できる状態にはなっているけど、家に転がってるサーバーが貧弱だから多分大量にリクエストが来たら速攻で鯖から煙が出ると思う。
だから一応の制限として使用するのにアカウントを要求させるようにするために今回のシステムを作ってみる。
取り敢えず基本部分だけ作って仕様はあとで考えてみる。
今のところの依存関係はこう。
[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がないから超安っぽい)
<!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を適用したページを出すのってどうすればいいんだろう。
調べた結果「rust-embed」を使えば実現できそう。
かなり分かりにくかったなぁ
色々見た目を調整した結果こんな感じに仕上がった。
今のところ分かっている問題点を上げると、
・まず、このままだともちろんダミーデータで全部通ってしまう。
・重複チェックをこの画面で行うとリスト型攻撃のリスクがある。
この2点が速攻で分かる。
対策としてメアド認証をしてから個人情報入力に誘導するようにする。
そのためにメールライブラリの「lettre」を追加。
[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を追加。
sqlx = { version = "0.6.2", features = ["runtime-tokio-native-tls", "postgres", "sqlite", "chrono"] }
メールを飛ばしたいが、ドメインの設定をしてなかったから反映待ち。
しばらくしたら再開
RedBullキメてたらいつの間にか反映されてた。さすがCloudflare早いぜ。
早速lettreをサンプルを参考にしながら使っていく。
同時にフォームを修正。
初めのスクリーンはメアド要求のみにした。
実装できたから試しに自分のメアドに送ってみる。
取り敢えずメアドを送るだけのテストだからサーバー側の最終結果は/
にリダイレクトされるようにした。
サンプルそのままだから抜粋して貼っておく。
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を使う方が正解かもしれない?
検証が成功すれば消すことになるから永続性ではなく速度を取る方向がいいかも。
あとで考えておく。
bb8-redisはredisをオーバーラッピングしている感じっぽいからredisは除けておく。
と、思ったけどなんか挙動があやレい。
bb8-redisじゃなくてbb8とredisを分けて導入して自分でPool処理を書いてみる。
bb8-redisとほぼ実装が同じだけどredis::Client::open::(T: IntoConnectionInfo)
を外に出した。
意味があるのかわからないけど私自身が分かりやすいからこれで。
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
ルートにアクセスしてメアドの認証をしてもらう。
で、入力して次に進むとPINコードの入力が始まる。
Thunderbirdで見るとメールはこんな感じ。
PINコードだけがCSSのuser-select
属性によって選択が可能にしてある。
して、メールの認証が終われば無事ユーザー登録フォームに案内される。
この一連の作業は10分以内に終わらせないとセッション有効期限が切れてエラーメッセージが出てくる
ユーザーに見えるのはこれだけ。
かなりシンプルだけど裏が結構ごっちゃごちゃなので公開していいところだけ。
実際のコードを挙げずに簡単なもので説明する。
まず、/signup
にアクセスした際にFromRequest<B>
を使ってセッション用のイベントを作る。
実装する先は構造体ではなく列挙体。こっちの方が明確に処理を分けることができるから。
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にそれぞれに合ったものを足していく。
pub enum SignupSession {
NewUser,
Resume,
Expired,
}
なんか書くのめんどくさくなってきたから閉じる