Rust × Rocket × Momento で実現する分散環境対応 OTP 二要素認証 ~データキャッシュによる認証状態管理の実装例~
Introduction
ワンタイムパスワード(OTP)を用いた二要素認証は、従来のパスワード認証に加え、
動的に生成される一時的なコードをもとに認証を行うことで、
(比較的簡単に)セキュリティを強化できる仕組みです。
本記事では、RustのフレームワークRocket を使ってOTP二要素認証を実装し、
さらにMomentoの分散キャッシュ機能を導入することで、複数サーバでも一元管理できるデモを作成してみます。
Momento?
Momentoはこのblogで紹介している、
クラウドネイティブな各種サーバレスサービスです。
データキャッシュ,メッセージング(Topics),ストレージと、
用途に応じて各種サービスを提供しています。
Environment
- MacBook Pro (14-inch, M3, 2023)
- OS : MacOS 14.5
- Rust : 1.83.0
Try
OTP 二要素認証の仕組み
まずはOTPの流れをおさらい。
OTP 二要素認証は、下記の流れで動作します。
-
初期設定(シークレットの共有)
ユーザーが二要素認証を有効にすると、シークレットキーが発行され、
認証アプリ(Google Authenticatorとか)への登録用に QR コードが生成されます。 -
OTP の生成
一般的によく使われるのが TOTP(Time-Based One-Time Password)方式です。
これは共有されたシークレットキーと現在時刻を組み合わせて6桁のコードを動的に生成します。
このコードは非常に短い有効期限(30秒とか)しか持たないので、不正利用やリプレイ攻撃に対して有効。 -
ログイン時の流れ
ユーザーはまずパスワード認証を行い、認証成功後に OTP 入力画面が表示されます。
認証アプリ(Google Authenticatorなど)で生成された OTP を入力し、
サーバー側では同じ計算法で生成されたコードと照合することで認証が完了します。
分散環境での OTP 状態管理(Momentoを使う)
例えば、ユーザーごとに一定回数以上の OTP 検証の状態(失敗回数やロック状態など)を管理したいとします。
分散環境では、各サーバーで状態を共有しなければいけません。
そこで、Momentoを使うことで、以下のようなことが可能になります。
- ユーザーごとに OTP 検証の試行回数をキャッシュに保存
- 一定回数の失敗があった場合、ブロック状態にして OTP 検証を停止
- どの認証サーバーにリクエストが来ても同じ状態を参照できるのでセキュリティと可用性向上
プロジェクトのセットアップ
MomentoのAPIキーとデモ用キャッシュを作成します。
このへんを見てキーとキャッシュを作成しましょう。
次にcargoでプロジェクト作成をします。
% cargo new two_factor_auth_demo
Cargo.tomlで必要なライブラリを設定。
rocket_dyn_templatesを使うと
Rocketでhandlebarsやteraなどのテンプレートエンジンが使えるようになります。
今回はhandlebarsを使います。
[dependencies]
base32 = "0.5.1"
chrono = "0.4.39"
hmac = "0.12.1"
momento = "0.49.0"
qrcode = "0.14.1"
rocket = "0.5.1"
serde = "1.0.217"
sha1 = "0.10.6"
[dependencies.rocket_dyn_templates]
version = "0.2.0"
features = ["handlebars", "tera"]
main.rsの実装について
主な処理をしているmain.rsについて解説します。(コード全文は最後に掲載)
QRコード生成
/qrcodeにアクセスすると、ユーザーがTOTPアプリ(Google Authenticatorなど)に
アカウントを登録する際に必要なQRコードを生成します。
ユーザー名(ここでは固定値 "user@example.com")と発行元("MyService")、
およびユーザー固有のシークレット文字列を使い、URLを作成します。
そのURLをバイト列に変換し、QrCode::new()でQRコードのデータを生成します。
最後にsvgでレスポンスを返しています。
これをGoogle Authenticatorなどに登録すればOK。
#[get("/qrcode")]
fn qrcode_svg(user_secret: &State<UserSecret>) -> (ContentType, String) {
let user = "user@example.com";
let issuer = "MyService";
let totp_url = format!(
"otpauth://totp/{}?secret={}&issuer={}",
user, user_secret.secret, issuer
);
let code = QrCode::new(totp_url.as_bytes()).expect("QRコード生成に失敗しました");
let svg: String = code.render::<Color>()
.min_dimensions(200, 200)
.build();
(ContentType::new("image", "svg+xml"), svg)
}
userとかissuerが適当な固定値になってますが、
本来はこのあたりもちゃんと設定しましょう。
TOTP(Time-Based One-Time Password)の生成
OTP用にシークレット文字列(Base32エンコードされた文字列)と現在時刻から一意の6桁コードを動的に生成します。
これをユーザー名、パスワード認証後に入力して一致すればログインOKとなります。
fn generate_totp(secret: &str, period: u64, digits: u32) -> Option<u32> {
let key = base32::decode(Alphabet::Rfc4648 { padding: false }, secret)?;
let now = Utc::now().timestamp() as u64;
let counter = now / period;
let counter_bytes = counter.to_be_bytes();
type HmacSha1 = Hmac<Sha1>;
let mut mac = HmacSha1::new_from_slice(&key).ok()?;
mac.update(&counter_bytes);
let result = mac.finalize().into_bytes();
let offset = (result[result.len() - 1] & 0x0f) as usize;
let code = ((result[offset] as u32 & 0x7f) << 24)
| ((result[offset + 1] as u32) << 16)
| ((result[offset + 2] as u32) << 8)
| (result[offset + 3] as u32);
Some(code % 10u32.pow(digits))
}
この処理は、HMAC-SHA1の計算結果から一意なOTPを算出しています。
これはRFC 4226で定義されているHOTP仕様に従ったアルゴリズムです。
最後に指定された桁数に調整してOTPとして返します。
二要素認証の検証とキャッシュ管理
ここでは、ユーザーが入力したOTPを検証し、Momentoを利用して試行回数の管理を行っています。
3回以上の失敗時には認証を一定時間ブロックします。
#[post("/2fa", data = "<otp_form>")]
async fn verify_2fa(
otp_form: Form<TwoFactorRequest>,
user_secret: &State<UserSecret>,
cache: &State<CacheState>,
) -> Template {
// 固定ユーザーID
// ※実際はセッション or DBなどから取得
let user_id = "demo_user";
let cache_key = format!("otp_attempts_{}", user_id);
// キャッシュから現在の試行回数を取得
let current_attempts: u32 = match cache
.client
.get(CACHE_NAME, cache_key.as_str())
.await
{
Ok(GetResponse::Hit { value }) => {
let s: String = value.try_into().unwrap_or_default();
s.parse().unwrap_or(0)
}
_ => 0,
};
rocket::info!("otp current_attempts: {}", current_attempts);
// 3回以上失敗している場合、OTP検証をブロック
if current_attempts >= 3 {
let context = ResultContext {
message: "OTP 試行回数超過のため、一時的に認証がブロックされています。しばらくしてからお試しください。".to_string()
};
return Template::render("result", context);
}
let expected = generate_totp(&user_secret.secret, 30, 6).unwrap_or(0);
if otp_form.otp == expected {
// 認証成功時はキャッシュから試行回数を削除
let _ = cache.client.delete(CACHE_NAME, cache_key.as_str()).await;
let context = ResultContext {
message: "二要素認証成功!ログイン完了です。".to_string()
};
Template::render("result", context)
} else {
// 認証失敗時は試行回数をインクリメントして保存(TTL はデフォルト)
let new_attempts = current_attempts + 1;
let _ = cache.client
.set(CACHE_NAME, cache_key.as_str(), new_attempts.to_string())
.await;
let context = ResultContext {
message: "OTP が無効です。ログイン失敗。".to_string()
};
Template::render("result", context)
}
}
Momentoのcache.client.getを使って、指定したユーザーのOTP検証試行回数をキャッシュから取得します。
試行回数が3回以上の場合、認証をブロックしてエラーとします。
入力されたOTPと、generate_totp によって生成された値を比較します。
Rocket サーバの初期化と Momento Clientの設定
Rocketの起動部分では、Momentoクライアントの初期化や各種設定をしています。
#[rocket::launch]
async fn rocket() -> _ {
// Momento のキャッシュクライアントを非同期で初期化
let cache_client = match CacheClient::builder()
.default_ttl(Duration::from_secs(60))
.configuration(configurations::Laptop::latest())
.credential_provider(
CredentialProvider::from_string(MOMENTO_API_KEY)
.expect("MOMENTO_API_KEY must be set"),
)
.build()
{
Ok(client) => client,
Err(err) => {
eprintln!("Momento クライアント初期化エラー: {:?}", err);
panic!("Momento クライアントの初期化に失敗しました");
}
};
rocket::build()
.attach(Template::fairing())
.manage(UserSecret { secret: "<secret key>".to_string() })
.manage(CacheState { client: cache_client })
.mount(
"/",
routes![index, login_page, login_handler, verify_2fa, qrcode_svg],
)
}
MomentoクライアントはCacheClient::builder() を使って初期化しています。
CredentialProvider::from_stringをつかって固定文字列から設定していますが、
実際は環境変数などもっと安全な方法をつかったほうが良いです。
動作確認
起動して動作確認してみましょう。
% cd path/your/two_factor_auth_demo
% cargo run
🔧 Configured for debug.
>> address: 127.0.0.1
>> port: 8000
>> workers: 8
>> max blocking threads: 512
>> ident: Rocket
>> IP header: X-Real-IP
>> limits: bytes = 8KiB, data-form = 2MiB, file = 1MiB, form = 32KiB, json = 1MiB, msgpack = 1MiB, string = 8KiB
>> temp dir: /var/folders/1n/xxxxxxxx/T/
>> http/2: true
>> keep-alive: 5s
>> tls: disabled
>> shutdown: ctrlc = true, force = true, signals = [SIGTERM], grace = 2s, mercy = 3s
>> log level: normal
>> cli colors: true
📬 Routes:
>> (index) GET /
>> (verify_2fa) POST /2fa
>> (login_page) GET /login
>> (login_handler) POST /login
>> (qrcode_svg) GET /qrcode
📡 Fairings:
>> Templating (ignite, liftoff, request)
>> Shield (liftoff, response, singleton)
🛡️ Shield:
>> X-Content-Type-Options: nosniff
>> Permissions-Policy: interest-cohort=()
>> X-Frame-Options: SAMEORIGIN
📐 Templating:
>> directory: templates
>> engines: ["tera", "hbs"]
🚀 Rocket has launched from http://127.0.0.1:8000
ブラウザでlocalhost:8000/qrcodeにアクセスするとQRコードが表示されます。
これをGoogle Authenticatorなどでスキャンして登録します。
その後、localhost:8000/loginにアクセスして適当なIDとパスワードで次へ進むと、
OTPの入力を促されます。
ここで先ほど登録した、認証アプリで生成されたOTPを入力すればログインが完了します。
※なお、一定時間内に3回失敗するとロックされます
Summary
本記事ではRustのRocketフレームワークを用いてOTPを使った二要素認証のデモを実装してみました。
また、分散環境でも認証状態管理できるように、Momentoを組み合わせてみました。
簡単な実装で通常のID/PASSWORD認証よりも強化できるので、検討してみてください。
References
- Rocket 公式ドキュメント
- rocket_dyn_templates クレート
- Momento Rust SDK GitHub
- Momento
- RFC 6238 – TOTP: Time-Based One-Time Password Algorithm
- やはりお前らの多要素認証は間違っている | DevelopersIO
Full source code for Demo
UI含むソース全文は以下。
src/main.rs
use rocket::{get, post, routes, State};
use rocket::form::{Form, FromForm};
use rocket::http::ContentType;
use chrono::Utc;
use hmac::{Hmac, Mac};
use sha1::Sha1;
use base32::Alphabet;
use qrcode::QrCode;
use qrcode::render::svg::Color;
use rocket_dyn_templates::Template;
use serde::Serialize;
use std::time::Duration;
#[macro_use]
extern crate rocket;
// Momento SDK の必要なモジュール
use momento::cache::{configurations, GetResponse};
use momento::{CacheClient, CredentialProvider};
const MOMENTO_API_KEY: &str = "Momento APIキー";
const CACHE_NAME: &str = "キャッシュ名";
/// OTP を生成する関数(RFC6238 準拠)
fn generate_totp(secret: &str, period: u64, digits: u32) -> Option<u32> {
let key = base32::decode(Alphabet::Rfc4648 { padding: false }, secret)?;
let now = Utc::now().timestamp() as u64;
let counter = now / period;
let counter_bytes = counter.to_be_bytes();
type HmacSha1 = Hmac<Sha1>;
let mut mac = HmacSha1::new_from_slice(&key).ok()?;
mac.update(&counter_bytes);
let result = mac.finalize().into_bytes();
let offset = (result[result.len() - 1] & 0x0f) as usize;
let code = ((result[offset] as u32 & 0x7f) << 24)
| ((result[offset + 1] as u32) << 16)
| ((result[offset + 2] as u32) << 8)
| (result[offset + 3] as u32);
Some(code % 10u32.pow(digits))
}
/// ユーザーごとの OTP シークレット(本実装では固定値)
struct UserSecret {
secret: String,
}
/// CacheClient を共有するためのラッパー
struct CacheState {
client: CacheClient,
}
/// ログイン用フォーム(本デモでは実認証は行いません)
#[derive(FromForm)]
struct LoginRequest {
username: String,
password: String,
}
/// 二要素認証用フォーム
#[derive(FromForm)]
struct TwoFactorRequest {
otp: u32,
}
/// テンプレート用コンテキスト
#[derive(Serialize)]
struct EmptyContext {}
#[derive(Serialize)]
struct ResultContext {
message: String,
}
/// GET: トップページ
#[get("/")]
fn index() -> Template {
Template::render("index", EmptyContext {})
}
/// GET: ログインページ
#[get("/login")]
fn login_page() -> Template {
Template::render("login", EmptyContext {})
}
/// POST: ログイン処理
/// ユーザー名・パスワード認証後、OTP入力画面へ遷移
#[post("/login", data = "<login>")]
fn login_handler(login: Form<LoginRequest>) -> Template {
let _ = &login;
Template::render("two_factor", EmptyContext {})
}
/// POST: OTP 検証+キャッシュ管理(Momento を利用)
/// demo_user 固定のユーザーIDに対して、失敗回数管理を行い3回以上の場合ブロックします
#[post("/2fa", data = "<otp_form>")]
async fn verify_2fa(
otp_form: Form<TwoFactorRequest>,
user_secret: &State<UserSecret>,
cache: &State<CacheState>,
) -> Template {
let user_id = "demo_user";
let cache_key = format!("otp_attempts_{}", user_id);
let current_attempts: u32 = match cache
.client
.get(CACHE_NAME, cache_key.as_str())
.await
{
Ok(GetResponse::Hit { value }) => {
let s: String = value.try_into().unwrap_or_default();
s.parse().unwrap_or(0)
}
_ => 0,
};
rocket::info!("OTP 現在の試行回数: {}", current_attempts);
if current_attempts >= 3 {
let context = ResultContext {
message: "OTP 試行回数超過のため、一時的に認証がブロックされています。しばらくしてからお試しください。".to_string()
};
return Template::render("result", context);
}
let expected = generate_totp(&user_secret.secret, 30, 6).unwrap_or(0);
if otp_form.otp == expected {
let _ = cache.client.delete(CACHE_NAME, cache_key.as_str()).await;
let context = ResultContext {
message: "二要素認証成功!ログイン完了です。".to_string()
};
Template::render("result", context)
} else {
let new_attempts = current_attempts + 1;
let _ = cache.client
.set(CACHE_NAME, cache_key.as_str(), new_attempts.to_string())
.await;
let context = ResultContext {
message: "OTP が無効です。ログイン失敗。".to_string()
};
Template::render("result", context)
}
}
/// GET: QRコード生成
#[get("/qrcode")]
fn qrcode_svg(user_secret: &State<UserSecret>) -> (ContentType, String) {
let user = "user@example.com";
let issuer = "MyService";
let totp_url = format!(
"otpauth://totp/{}?secret={}&issuer={}",
user, user_secret.secret, issuer
);
let code = QrCode::new(totp_url.as_bytes()).expect("QRコード生成に失敗しました");
let svg: String = code.render::<Color>()
.min_dimensions(200, 200)
.build();
(ContentType::new("image", "svg+xml"), svg)
}
/// Rocket の起動(Tokio 1.x ランタイム上の非同期関数)
#[rocket::launch]
async fn rocket() -> _ {
let cache_client = match CacheClient::builder()
.default_ttl(Duration::from_secs(60))
.configuration(configurations::Laptop::latest())
.credential_provider(
CredentialProvider::from_string(MOMENTO_API_KEY)
.expect("MOMENTO_API_KEY must be set"),
)
.build()
{
Ok(client) => client,
Err(err) => {
eprintln!("Momento クライアント初期化エラー: {:?}", err);
panic!("Momento クライアントの初期化に失敗しました");
}
};
rocket::build()
.attach(Template::fairing())
.manage(UserSecret { secret: "<secret key>".to_string() })
.manage(CacheState { client: cache_client })
.mount(
"/",
routes![index, login_page, login_handler, verify_2fa, qrcode_svg],
)
}
templates/index.html.hbs
<html>
<head>
<title>2FA Demo</title>
</head>
<body>
<h1>2FA Demo</h1>
<ul>
<li><a href="/login">Login</a></li>
<li><a href="/qrcode" target="_blank">QR Code(For App Registration)</a></li>
</ul>
</body>
</html>
templates/login.html.hbs
<html>
<head>
<title>Login</title>
</head>
<body>
<h2>Login</h2>
<form method="post" action="/login">
User Name(anything OK): <input type="text" name="username" /><br/>
PassWord(anything OK): <input type="password" name="password" /><br/>
<button type="submit">Login</button>
</form>
</body>
</html>
templates/two_factor.html.hbs
<html>
<head>
<title>two factor auth</title>
</head>
<body>
<h2>Password authentication succeeded</h2>
<p>Next, enter the OTP generated by the auth app.</p>
<form method="post" action="/2fa">
OTP: <input type="number" name="otp" /><br/>
<button type="submit">Auth</button>
</form>
<p>To register your account with the Auth App, please use <a href=“/qrcode” target=“_blank”>QR Code</a></p>
</body>
</html>
templates/result.html.hbs
<html>
<head>
<title>Certification Results</title>
</head>
<body>
<h2>{{message}}</h2>
<a href="/">Top Page</a>
</body>
</html>
提示したコードはあくまでデモ実装です。
実際に使用する場合、各種セキュリティ対策(key管理、時刻同期など)を追加してください。