Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

認証

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. パスワードを保存する際に正しい方法は?

Q2. セッションCookieのHttpOnly属性の目的は?

Q3. ログイン処理で、ユーザーが見つからない場合とパスワードが間違っている場合のレスポンスとして適切なのは?

Q4. 以下のコードの問題は?
sqlx::query("INSERT INTO users (email, password) VALUES (?, ?)").bind(&email).bind(&password)

Q5. JWTとセッションベース認証の違いとして正しいのは?


次のドキュメント: 06_frontend_integration.md