認証
Webアプリケーションの認証の基礎を学びます。
認証とは
認証(Authentication): 「あなたは誰か」を確認すること 認可(Authorization): 「何ができるか」を確認すること
┌─────────────┐ 認証 ┌─────────────┐ 認可 ┌─────────────┐
│ ユーザー │ ───────────→ │ 本人確認 │ ───────────→ │ 権限確認 │
│ │ ID/Password │ OK/NG │ │ 許可/拒否 │
└─────────────┘ └─────────────┘ └─────────────┘
認証方式の種類
1. セッションベース認証
┌────────┐ ┌────────────┐ ┌────────┐
│ Client │ ──Login──→│ Server │ ←───────→ │ Session│
│ │ ←Cookie── │ │ │ Store │
│ │ ──Cookie─→│ 検証 │ │ │
└────────┘ └────────────┘ └────────┘
特徴:
- サーバーがセッション情報を保持
- Cookieでセッション IDを送受信
- 状態を持つ(ステートフル)
2. トークンベース認証(JWT)
┌────────┐ ┌────────────┐
│ Client │ ──Login──→│ Server │
│ │ ←JWT───── │ │
│ │ ──JWT────→│ 検証 │ ← トークン自体に情報
└────────┘ └────────────┘
特徴:
- トークン自体に情報を含む
- サーバーは状態を持たない(ステートレス)
- スケールしやすい
セッションベース認証の実装
Cargo.toml
[dependencies]
axum = "0.7"
axum-extra = { version = "0.9", features = ["cookie"] }
tokio = { version = "1", features = ["full"] }
tower-sessions = "0.12"
tower-sessions-sqlx-store = { version = "0.12", features = ["sqlite"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
serde = { version = "1.0", features = ["derive"] }
uuid = { version = "1", features = ["v4"] }
argon2 = "0.5" # パスワードハッシュ
パスワードのハッシュ化
絶対にパスワードを平文で保存しない!
#![allow(unused)]
fn main() {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
// パスワードをハッシュ化
fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2.hash_password(password.as_bytes(), &salt)?;
Ok(hash.to_string())
}
// パスワードを検証
fn verify_password(password: &str, hash: &str) -> bool {
let parsed_hash = match PasswordHash::new(hash) {
Ok(h) => h,
Err(_) => return false,
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok()
}
}
セッション管理
#![allow(unused)]
fn main() {
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use tower_sessions::{Session, SessionManagerLayer};
use tower_sessions_sqlx_store::SqliteStore;
const USER_ID_KEY: &str = "user_id";
#[derive(Deserialize)]
struct LoginRequest {
email: String,
password: String,
}
#[derive(Serialize)]
struct UserResponse {
id: i64,
email: String,
}
// ログイン
async fn login(
session: Session,
State(pool): State<SqlitePool>,
Json(payload): Json<LoginRequest>,
) -> Result<Json<UserResponse>, StatusCode> {
// ユーザーを検索
let user = sqlx::query_as::<_, User>(
"SELECT id, email, password_hash FROM users WHERE email = ?"
)
.bind(&payload.email)
.fetch_optional(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
// パスワードを検証
if !verify_password(&payload.password, &user.password_hash) {
return Err(StatusCode::UNAUTHORIZED);
}
// セッションにユーザーIDを保存
session
.insert(USER_ID_KEY, user.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(UserResponse {
id: user.id,
email: user.email,
}))
}
// ログアウト
async fn logout(session: Session) -> StatusCode {
session.delete().await;
StatusCode::OK
}
// 現在のユーザーを取得
async fn current_user(
session: Session,
State(pool): State<SqlitePool>,
) -> Result<Json<UserResponse>, StatusCode> {
// セッションからユーザーIDを取得
let user_id: i64 = session
.get(USER_ID_KEY)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
// ユーザー情報を取得
let user = sqlx::query_as::<_, User>(
"SELECT id, email FROM users WHERE id = ?"
)
.bind(user_id)
.fetch_optional(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(UserResponse {
id: user.id,
email: user.email,
}))
}
}
メイン関数
use sqlx::sqlite::SqlitePool;
use tower_sessions::SessionManagerLayer;
use tower_sessions_sqlx_store::SqliteStore;
#[tokio::main]
async fn main() {
let pool = SqlitePool::connect("sqlite:./database.db").await.unwrap();
// セッションストアを作成
let session_store = SqliteStore::new(pool.clone());
session_store.migrate().await.unwrap();
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false) // 開発環境用(本番ではtrue)
.with_http_only(true);
let app = Router::new()
.route("/login", post(login))
.route("/logout", post(logout))
.route("/me", get(current_user))
.layer(session_layer)
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
認証ミドルウェア
認証が必要なルートを保護するミドルウェアを作成します。
#![allow(unused)]
fn main() {
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::Response,
};
use tower_sessions::Session;
// 認証チェックミドルウェア
async fn require_auth(
session: Session,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
// セッションにユーザーIDがあるかチェック
let user_id: Option<i64> = session
.get(USER_ID_KEY)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if user_id.is_none() {
return Err(StatusCode::UNAUTHORIZED);
}
// 次のハンドラーへ
Ok(next.run(request).await)
}
}
ミドルウェアの適用
#![allow(unused)]
fn main() {
use axum::middleware;
let app = Router::new()
// 認証不要なルート
.route("/login", post(login))
.route("/register", post(register))
// 認証が必要なルート
.route("/me", get(current_user))
.route("/bookmarks", get(list_bookmarks).post(create_bookmark))
.route_layer(middleware::from_fn(require_auth))
.layer(session_layer)
.with_state(pool);
}
JWT認証(参考)
JWTを使った認証の概要です。
JWTの構造
ヘッダー.ペイロード.署名
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Cargo.toml
[dependencies]
jsonwebtoken = "9"
JWT生成・検証
#![allow(unused)]
fn main() {
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String, // ユーザーID
exp: usize, // 有効期限
}
const SECRET: &[u8] = b"your-secret-key"; // 本番では環境変数から
// JWT生成
fn create_token(user_id: i64) -> Result<String, jsonwebtoken::errors::Error> {
let expiration = chrono::Utc::now()
.checked_add_signed(chrono::Duration::hours(24))
.unwrap()
.timestamp() as usize;
let claims = Claims {
sub: user_id.to_string(),
exp: expiration,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(SECRET),
)
}
// JWT検証
fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(SECRET),
&Validation::default(),
)?;
Ok(token_data.claims)
}
}
セキュリティのベストプラクティス
1. パスワード
- 平文保存禁止: 必ずハッシュ化(argon2, bcrypt)
- 強度要件: 最低8文字、複雑さの要件
- レート制限: ブルートフォース攻撃対策
2. セッション
- HttpOnly: JavaScriptからアクセス禁止
- Secure: HTTPS必須(本番環境)
- SameSite: CSRF対策
#![allow(unused)]
fn main() {
let session_layer = SessionManagerLayer::new(session_store)
.with_http_only(true)
.with_secure(true) // 本番環境
.with_same_site(SameSite::Strict);
}
3. 一般的な対策
- HTTPS必須(本番環境)
- CORS設定を適切に
- 入力値のバリデーション
- エラーメッセージに詳細を含めない
まとめ
| 方式 | 特徴 | 用途 |
|---|---|---|
| セッション | ステートフル、サーバー側で管理 | 従来型Webアプリ |
| JWT | ステートレス、トークンに情報含む | API、マイクロサービス |
| セキュリティ対策 |
|---|
| パスワードのハッシュ化(argon2) |
| セッションのHttpOnly/Secure設定 |
| HTTPS必須(本番) |
| 入力値のバリデーション |
確認テスト
Q1. パスワードを保存する際に正しい方法は?
正解: C) パスワードは一方向ハッシュ関数(argon2, bcryptなど)でハッシュ化して保存します。暗号化は復号できるため、ハッシュ化より安全性が低いです。
Q2. セッションCookieのHttpOnly属性の目的は?
正解: B)
HttpOnlyはJavaScriptからCookieにアクセスできなくし、XSS攻撃によるセッションハイジャックを防ぎます。HTTPSでのみ送信するのはSecure属性です。
Q3. ログイン処理で、ユーザーが見つからない場合とパスワードが間違っている場合のレスポンスとして適切なのは?
正解: A) セキュリティ上、どちらも同じ401を返すべきです。異なるレスポンスを返すと「ユーザーが存在するか」という情報が漏洩し、攻撃者に有利な情報を与えてしまいます。
Q4. 以下のコードの問題は?sqlx::query("INSERT INTO users (email, password) VALUES (?, ?)").bind(&email).bind(&password)
正解: D) パスワードを平文で保存するのは重大なセキュリティ問題です。必ずargon2やbcryptでハッシュ化してから保存する必要があります。
Q5. JWTとセッションベース認証の違いとして正しいのは?
正解: C) JWTはトークン自体に情報を含むためサーバーは状態を持たない(ステートレス)。セッションベースはサーバーがセッション情報を保持する(ステートフル)ため、JWTの方がスケールしやすいです。
次のドキュメント: 06_frontend_integration.md