[Rust] JWTを学習後、ゼロからJWTを使った認証・認可プロセスを実装してみた
この記事を書くにあたって
自作アプリ(Rust)にログイン画面を実装する際に、JWTを利用したリクエスト認証・認可プロセスを実装しました。
ゼロからJWTを使用した認証・認可プロセスを実装したのは今回が初めてだったこともあり、学習の記録及び今後同じく似たようなものを実装をする時のための実装メモの様なものを残したいと思ったのでここに書き留めておきます。
※JWTの認証&認可の仕組みやJWTトークンの3つからなる構成など基本的な説明は割愛します。
実装にあたり前提としておくこと
ここでは
ログイン&JWTトークンを発行の認証と
JWTトークンデコード&アクセス権限検証の 認可と
大きく2つに分けて実装していきます。
上記を踏まえ、おおまかな実装方針や前提情報を記載しておきます。
処理シーケンス図
・認証処理(ログイン&JWTトークンを発行)のシーケンス
ログイン認証後にJWTトークンを発行してレスポンスボディにトークンを含めてブラウザに保存する方法で実装します。
・認可処理(JWTトークンデコード&アクセス権限検証)のシーケンス
ブラウザに保存済みのトークンをHTTPヘッダーに設定してリクエストし、デコードしトークンのヘッダー、ペーロードが署名とあっているか確認する。
エンコード時に使用するアルゴリズム
エンコードする際のアルゴリズムには「HS256」(HMAC-SHA256)を使用します。
ペイロードの元となるClaims項目
ペイロードとなるClaimsには以下の構造体の項目を使用します。
struct Claims {
iss: String, //発行者(サイトのドメイン)
sub: i32, // ユーザーID(DBレコードのユニークキー)
user_name: String, // ログインID(メアド)
iat: i64, // 発行日時(UNIXタイムスタンプ)
exp: i64, // 有効期限(UNIXタイムスタンプ)
}
事前に設定済みのログイン認証DB
ログイン認証に必要なDB情報はあらかじめ以下の情報を設定しておきます。
※saltはランダムに生成した文字列
※hashには「salt + PWをSha256で生成した文字列」を設定してます。
実装
実装の前に簡単なフローチャートを書き示してきます。
※実装ソースのuse文は省略します。
フローチャート - 認証処理
実際に実装したソース - 認証処理
・主な処理は以下になります。
jwtトークン発行のメイン処理はjsonwebtoken::encode()
引数に、Claimsとエンコーディングキーをセット
「HS256」のアルゴリズムを使用するので、Header::default()を使用します。
※エンコーディングキーはハードコーディングせず.envなどから取得するようにしてください。
ログイン&JWTトークン発行のメインソース
#[derive(Serialize, Deserialize)]
//リクエストボディ
pub struct RequestForm {
login_id: String, // ログインID
password: String, // ログインPW
}
#[derive(Serialize)]
// レスポンスボディ
pub struct LoginResponse {
message: String, // ログイン結果メッセージ
token: Option<String>, // jwtトークン
}
#[derive(Debug, Serialize, Deserialize)]
// Claims
struct Claims {
iss: String, //発行者(サイトのドメイン)
sub: i32, // ユーザーID
user_name: String, // ログインID
iat: i64, // 発行日時(UNIXタイムスタンプ)
exp: i64, // 有効期限(UNIXタイムスタンプ)
}
pub async fn check_password(Json(payload): Json<RequestForm>) ->Response {
//DB接続情報
let db_info = DbInfo::new().await;
let db:&DbConn = db_info.get_db_connection();
//ログインID、PWなどの情報をテーブルから取得する
let auth_info:Option<auth::Model> = get_auth_info(&db, &payload).await.expect("database select error!");
// レコード取得後にログイン情報をセットする変数
let user_id:i32;
let salt:String;
let hash:String;
let expire_date:DateTime<Local>;
// SELECT後、レコードの有無をチェックする。
match auth_info {
// 取得レコードがある場合、ログイン情報をセットする。
Some(record) => {
user_id = record.id;
salt = record.salt.unwrap();
hash = record.hash.unwrap();
expire_date = Local
.from_local_datetime(&record.expire_date.unwrap()
.and_hms_opt(0, 0, 0).unwrap())
.unwrap();
},
// レコードがない場合、ユーザーが存在しない旨401で返す。
None => {
let response = LoginResponse {
message: "Invalid credentials".to_string(),
token: None,
};
return (StatusCode::UNAUTHORIZED, Json(response)).into_response();
}
}
// ユーザーIDとPWの組み合わせが正しいかチェック
if salt + &generate_hash_password(&payload.password) == hash{
// ログイン情報が正しい場合、jwtトークンを発行する
// 現在時刻(UNIX時間)を取得
let dt: DateTime<Local> = Local::now();
// Claimsをセット
let claims = Claims {
iss: "www.example-service.com".to_string(),
sub: user_id,
user_name: payload.login_id,
iat: dt.timestamp(),
exp: expire_date.timestamp()
};
// jwtトークン発行
let token = encode(
&Header::default(),
&claims,
// エンコーディングキーはハードコーディングせず.envなどから取得するようにしてください。
&EncodingKey::from_secret("XXXXXXXXXXXXXXXXXXXXXXXXXXXX".as_ref()),
)
.unwrap();
// 認証成功。レスポンスにJWTトークンを含めて200で返す。
let response = LoginResponse {
message: "Login successful".to_string(),
token: Some(token),
};
(StatusCode::OK, Json(response)).into_response()
} else {
// ユーザーIDとPWの組み合わせ不整合として401で返す。
let response = LoginResponse {
message: "Invalid credentials".to_string(),
token: None,
};
(StatusCode::UNAUTHORIZED, Json(response)).into_response()
}
}
//ログインIDやパスワードやsoltなどをテーブルから取得する
async fn get_auth_info(db:&DbConn, request_data: &RequestForm) -> Result<Option<auth::Model>, DbErr> {
auth::Entity::find()
.filter(auth::Column::LoginId.eq(request_data.login_id.clone()))
.one(db) // `one`メソッドを使って1つのレコードを取得
.await
}
// ハッシュ化関数
fn generate_hash_password(password: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(password);
let result = hasher.finalize();
let mut hash_string = String::new();
for byte in result {
write!(&mut hash_string, "{:02x}", byte).unwrap();
}
hash_string
}
フローチャート - 認可処理
実際に実装したソース - 認可処理
・主な処理は以下になります。
リクエスト時にHTTPヘッダーAuthorizationにJWTトークンを付与。
Authorizationヘッダーからトークンと取得して、jsonwebtoken::decodeでデコードしてmatch関数で署名を検証する
※この先、どのハンドラーでも共通の処理とさせたいためミドルウェアとして実装しています。
ルーティング設定するソース
pub async fn running_router() {
let app = axum::Router::new()
.route("/login", routing::post(login::check_password))
// 権限が必要なリクエスト
.route("/my-page", routing::get(show_career::show_my_page)
// ミドルウエアとして実装
.route_layer(middleware::from_fn(auth_jwt::auth_middleware))
);
dotenv().ok();
axum::Server::bind(&env::var("HOST_NAME").unwrap().parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
JWTトークンデコードのソース
#[derive(Debug, Serialize, Deserialize)]
// Claims
struct Claims {
iss: String, //発行者(サイトのドメイン)
sub: i32, // ユーザーID
user_name: String, // ログインID
iat: i64, // 発行日時(UNIXタイムスタンプ)
exp: i64, // 有効期限(UNIXタイムスタンプ)
}
pub async fn auth_middleware<B>(req: Request<B>, next: Next<B>) -> Result<Response, StatusCode> {
// JWTシークレットキー(サーバー側で設定したものと一致させる)
const SECRET_KEY: &[u8] = b"XXXXXXXXXXXXXXXXXXXXXXXXXXXX";
// Authorizationヘッダーからトークンを取得
let auth_header = req.headers().get("Authorization");
if let Some(auth_header_value) = auth_header {
if let Ok(auth_str) = auth_header_value.to_str() {
// トークン部分だけ取得。("Bearer <token>"形式を想定)
if let Some(token) = auth_str.strip_prefix("Bearer ") {
// トークンをデコードして検証
match decode::<Claims>(
token,
&DecodingKey::from_secret(SECRET_KEY),
&Validation::default(),
) {
Ok(token_data) => {
// 検証成功時は次のハンドラーに処理を移譲
return Ok(next.run(req).await);
}
Err(err) => {
//検証失敗時は401としてレスポンスを返す
return Err(StatusCode::UNAUTHORIZED);
}
}
}
}
}
// トークンが存在しないまたはフォーマットが不正
Err(StatusCode::UNAUTHORIZED)
}
Rust、ライブラリのVer
rustc 1.78.0 (9b00956e5 2024-04-29)
axum = "0.6.9"
jsonwebtoken = "8.1"
Discussion